Skip to content

The Work Loop and Time Slicing

advanced11 min read

The Loop That Runs React

This might surprise you: every React render -- every setState, every context change, every parent re-render -- ultimately becomes a series of calls inside a single while loop. That's it. This loop processes one fiber node at a time and asks one question between each node: "Should I give the browser a chance to do something?"

// The actual core of React's rendering engine (simplified from source)
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

Three lines. That's the entire engine. Seriously. Everything else -- hooks, reconciliation, effects, suspense -- happens inside performUnitOfWork. The loop itself just decides when to stop.

The Mental Model

Mental Model

Picture a bricklayer building a wall. Each brick is one fiber node. After placing each brick, the bricklayer glances at a timer. If there's still time in the current 5ms window, they place another brick. If time is up, they set down their tools, let the building inspector check the structure (browser paints, handles events), then pick up where they left off. The wall gets built either way — but the inspector never has to wait more than one brick's worth of time.

The shouldYield() function is the timer check. performUnitOfWork is placing one brick. The while loop is the bricklayer's work rhythm.

Two Modes: Sync and Concurrent

Here's something that will make the whole concurrent rendering story click. React has two versions of the work loop:

// Synchronous: processes everything without yielding
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

// Concurrent: yields to the browser between units
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

The only difference is && !shouldYield(). Synchronous mode runs for urgent updates (user typing, clicks). Concurrent mode runs for transitions and deferred work.

Info

shouldYield() checks if 5ms have elapsed since the current time slice started. This 5ms budget comes from the scheduler — it's tuned to leave enough time for the browser to paint at 60fps (each frame gets ~16ms, React takes at most 5ms of that).

Quiz
What is the only difference between workLoopSync and workLoopConcurrent?

performUnitOfWork: One Fiber at a Time

Each call to performUnitOfWork does two things:

  1. beginWork — Process the current fiber: call the component function, diff children, create child fibers
  2. Move to next — Follow the child/sibling/return linked list to find the next fiber
function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate; // The committed version of this fiber

  // Phase 1: Process this fiber (render the component, diff children)
  const next = beginWork(current, unitOfWork, renderLanes);

  // Save the pending props as memoized (they've been processed)
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // No children — complete this fiber and find next work
    completeUnitOfWork(unitOfWork);
  } else {
    // Has children — the first child is the next unit of work
    workInProgress = next;
  }
}
Execution Trace
Step 1:
performUnitOfWork(App)
beginWork(App) → calls App(), diffs children → returns Header fiber as next
Step 2:
performUnitOfWork(Header)
beginWork(Header) → calls Header(), diffs children → returns Logo fiber
Step 3:
performUnitOfWork(Logo)
beginWork(Logo) → calls Logo(), no children → returns null
Step 4:
completeUnitOfWork(Logo)
No child returned. Complete Logo, bubble up flags. Move to sibling Nav
Yield?:
shouldYield() check
5ms elapsed? If yes, pause. Browser paints. Resume at Nav later
Step 5:
performUnitOfWork(Nav)
Processing continues from exactly where it paused

beginWork: The Render Phase Engine

This is where the real action happens. beginWork receives the current fiber (committed) and the workInProgress fiber (being built), then decides what to do based on the fiber tag:

function beginWork(current, workInProgress, renderLanes) {
  // Optimization: if props and state haven't changed, bail out early
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (oldProps === newProps && !hasContextChanged()) {
      // Nothing changed — reuse the existing children
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }

  // Different logic based on fiber type
  switch (workInProgress.tag) {
    case FunctionComponent:
      return updateFunctionComponent(current, workInProgress, renderLanes);
    case ClassComponent:
      return updateClassComponent(current, workInProgress, renderLanes);
    case HostComponent: // <div>, <span>, etc.
      return updateHostComponent(current, workInProgress, renderLanes);
    case SuspenseComponent:
      return updateSuspenseComponent(current, workInProgress, renderLanes);
    // ... 20+ fiber tags
  }
}

For function components, updateFunctionComponent calls your component function, runs hooks, and reconciles the returned elements against the existing children.

The key insight: beginWork always returns the first child fiber (or null if there are no children). This is how the traversal goes depth-first.

Quiz
What does beginWork return when processing a component?

completeWork: Building Up from the Leaves

When beginWork returns null (no children), the fiber enters the "complete" phase. completeWork walks back up the tree via return pointers:

function completeUnitOfWork(unitOfWork) {
  let completedWork = unitOfWork;

  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    // Create/update the actual DOM node for host components
    completeWork(current, completedWork, renderLanes);

    // Bubble effect flags up to parent
    if (returnFiber !== null) {
      returnFiber.subtreeFlags |= completedWork.subtreeFlags;
      returnFiber.subtreeFlags |= completedWork.flags;
    }

    // Move to sibling if one exists
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      workInProgress = siblingFiber;
      return; // Process the sibling's subtree next
    }

    // No sibling — move up to parent and complete it
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
}

During completeWork:

  • Host components (div, span): Create the actual DOM node (but don't insert it yet)
  • Function/class components: Nothing to create — they're virtual
  • All fibers: Bubble up effect flags (subtreeFlags) so the commit phase knows which subtrees need work
The subtreeFlags optimization

Before React 18, the commit phase walked the entire fiber tree looking for fibers with effects. With subtreeFlags, each fiber aggregates its children's flags. During commit, if a fiber's subtreeFlags is 0 (no child effects), the commit phase skips that entire subtree. For large trees where only a small part changed, this dramatically reduces commit phase work.

// During completeWork, flags bubble up:
// If a deep child has Placement flag, every ancestor
// gets that flag in subtreeFlags
parent.subtreeFlags |= child.flags | child.subtreeFlags;

This means the commit phase can skip 90% of the tree when only 10% of nodes have effects.

Quiz
During completeWork, what happens with effect flags?

shouldYield: The 5ms Budget

After all this complexity, shouldYield() is deceptively simple:

function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) { // frameInterval = 5ms
    return false; // Keep working
  }
  return true; // Time's up, yield to browser
}

When shouldYield() returns true, the work loop exits. The scheduler then posts a message (via MessageChannel) that schedules the next chunk of work as a new macrotask. The browser gets to paint and handle events in between.

Timeline:
|--React work (5ms)--|--Browser paint + events--|--React work (5ms)--|--Browser--|...
Common Trap

shouldYield is only checked in workLoopConcurrent. Synchronous renders (workLoopSync) skip the check entirely. This means normal setState calls (not wrapped in startTransition) still block the main thread for the full render duration. Fiber's time-slicing only activates for concurrent features like transitions, Suspense, and useDeferredValue.

Production Scenario: Measuring the Work Loop

You can see the work loop in action using the React Profiler and Performance tab:

// Force a large synchronous render to see the difference
function HeavyList() {
  const items = Array.from({ length: 10000 }, (_, i) => i);
  return items.map(i => <HeavyItem key={i} value={i} />);
}

// Synchronous: one long task in Performance tab
function SyncVersion() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(true)}>Show List</button>
      {show && <HeavyList />}
    </>
  );
}

// Concurrent: many small tasks separated by browser work
function ConcurrentVersion() {
  const [show, setShow] = useState(false);
  const [isPending, startTransition] = useTransition();
  return (
    <>
      <button onClick={() => startTransition(() => setShow(true))}>
        Show List
      </button>
      {isPending && <Spinner />}
      {show && <HeavyList />}
    </>
  );
}

In the Performance tab:

  • SyncVersion shows one long task of 200ms+
  • ConcurrentVersion shows many 5ms tasks with browser work (paint, event handling) interleaved between them

The total render time might be slightly longer for the concurrent version (overhead of yielding and resuming), but the UI never locks up. The spinner shows immediately while the list renders in the background.

The Full Traversal Algorithm

Putting it all together, here's the complete fiber traversal in pseudocode:

1. Start at root fiber
2. while (workInProgress !== null && !shouldYield()):
   a. Call beginWork(workInProgress)
      - Renders the component (calls function, runs hooks)
      - Diffs children against current tree
      - Returns first child fiber, or null
   b. If child returned:
      - Set workInProgress = child
      - Continue loop (go deeper)
   c. If no child (null returned):
      - Call completeWork(workInProgress)
        - Create DOM nodes for host components
        - Bubble effect flags to parent
      - If sibling exists:
        - Set workInProgress = sibling
        - Continue loop (go sideways)
      - If no sibling:
        - Walk up via return pointer, completing each ancestor
        - Stop when reaching a fiber with a sibling, or the root
3. If shouldYield() interrupted:
   - Save workInProgress (the current position)
   - Schedule a callback to resume later
   - Browser gets control for painting and events
4. Resume: re-enter the while loop from saved workInProgress

Common Mistakes

Common Mistakes
  • Wrong: Expecting all setState calls to be time-sliced Right: Only transitions use workLoopConcurrent. Normal setState uses workLoopSync

  • Wrong: Putting expensive computation directly in render functions Right: Use useMemo for expensive calculations, or move computation to a transition

  • Wrong: Thinking shouldYield checks after every line of code Right: shouldYield is checked between fiber nodes, not within a single component's render

  • Wrong: Assuming time-slicing eliminates the need for React.memo or useMemo Right: Time-slicing and memoization solve different problems. Use both where appropriate

Challenge

Predict the work loop behavior

Show Answer
  1. Update A (setCount) is processed by workLoopSync. It runs to completion without yielding. The counter updates immediately.

  2. Update B (setList inside startTransition) is processed by workLoopConcurrent. It yields every 5ms, allowing browser paint and event handling between chunks.

  3. Yes. If the user clicks again while Update B is rendering, the new click triggers a synchronous update. React discards the in-progress workInProgress tree for Update B, processes the new click synchronously, then restarts Update B from scratch.

  4. The user sees the counter increment to 1 and the Spinner appear immediately (synchronous update). The list renders in the background. When done, the Spinner is replaced by the List (transition completes).

Key Rules

Key Rules
  1. 1The work loop processes one fiber per iteration, checking shouldYield() between each. This is how React achieves 5ms time slices.
  2. 2performUnitOfWork calls beginWork (render the component, create child fibers) then either descends to the child or completes and moves to a sibling.
  3. 3beginWork goes down the tree (child direction). completeWork goes up (return direction), creating DOM nodes and bubbling effect flags.
  4. 4shouldYield() uses a 5ms budget. After 5ms, React yields to the browser via MessageChannel, then resumes in the next macrotask.
  5. 5Only workLoopConcurrent checks shouldYield(). Synchronous renders (normal setState) run the entire tree without interruption.
  6. 6One component render is one unit of work. Time-slicing can't interrupt a single slow component — only the gaps between components.