The Work Loop - Render Phase

performWorkOnRoot decides whether this render may be time-sliced, then calls renderRootSync or renderRootConcurrent. Both clone the HostRoot into a work-in-progress fiber…

Published
Reading time
11 min read
React InternalsPart 06 · 7 of 18

Where we enter

By the time you get here, an update has already been queued on a fiber and a lane assigned (root.render, setState). The root scheduler then calls into the work loop in one of two ways (Lanes & Scheduler):

  • Sync lanes (e.g. a click → SyncLane): scheduleTaskForRootDuringMicrotask does not create a Scheduler task. It sets root.callbackPriority = SyncLane, and the microtask flush calls performSyncWorkOnRoot, which calls performWorkOnRoot(root, lanes, forceSync = true).12
  • Everything else: a Scheduler task is posted with performWorkOnRootViaSchedulerTask.bind(null, root). That task first flushes any pending passive effects, recomputes getNextLanes, calls performWorkOnRoot, and returns itself as a continuation if the root still has work at that priority.3

That continuation return is how a time-sliced render picks up where it left off on the next macrotask. It is not a recursive call. Scheduler sees a function come back and keeps it as the same task.

performWorkOnRoot: the shouldTimeSlice decision

JavaScript
// ReactFiberWorkLoop.js (simplified)
export function performWorkOnRoot(root, lanes, forceSync) {
  if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
    throw new Error('Should not already be working.');
  }
  const shouldTimeSlice =
    (!forceSync &&
      !includesBlockingLane(lanes) &&
      !includesExpiredLane(root, lanes)) ||
    checkIfRootIsPrerendering(root, lanes);

  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes, true);
  // ... loop handling RootInProgress / errors / store tearing ...
  finishConcurrentRender(root, exitStatus, finishedWork, lanes, renderEndTime);
  // ...
  ensureRootIsScheduled(root);
}

4

The decision that matters is includesBlockingLane. Its "blocking" set is SyncLane, InputContinuousLane, DefaultLane, the hydration variants of those, and GestureLane.5 That has a consequence people often miss:

The other two inputs:

  • includesExpiredLane: a lane that has waited too long gets marked expired, and from then on it renders synchronously so it cannot be starved.
  • checkIfRootIsPrerendering: if the root is only prerendering (warming up siblings of a suspended tree that can't commit yet), React always uses the concurrent loop, even for sync lanes, so that this speculative work never blocks the main thread.4

After the render returns, performWorkOnRoot handles a few exit cases:

  • RootInProgress: the concurrent loop yielded. Break out. The scheduler task will return a continuation.
  • Store tearing check: if the render was concurrent, isRenderConsistentWithExternalStores(finishedWork) walks the tree to check that every useSyncExternalStore snapshot is still current. If one changed while React was yielded, it re-renders synchronously with renderRootSync(root, lanes, false) so nothing can interleave this time.6
  • Errors: RootErrored gets one synchronous retry via recoverFromConcurrentError. RootFatalErrored resets the stack.4
  • Otherwise it calls finishConcurrentRender, which either commits right away or, for suspended transitions and retries, marks the root suspended and may schedule a throttled completeRootWhenReady via scheduleTimeout.7 completeRootWhenReady can also suspend the commit (waiting on stylesheets/images or an active View Transition) before calling completeRoot → commitRoot.8

renderRootSync vs renderRootConcurrent

Both functions have the same skeleton:

  1. Set executionContext |= RenderContext and push the hooks dispatcher (Bootstrap).
  2. If the root or the lanes changed since the last time we were here, call prepareFreshStack. If not, continue the existing work-in-progress.910
  3. Enter an outer: do { try { ... workLoopX() } catch (thrownValue) { handleThrow(...) } } while (true) loop.
  4. Restore context. If workInProgress === null the tree is done, so clear workInProgressRoot and return the exit status. Otherwise return RootInProgress.1112

They differ in how they handle a thrown promise (suspension) and in which inner loop they use:

renderRootSyncrenderRootConcurrent
Inner loopworkLoopSync (never checks time)workLoopConcurrentByScheduler (or workLoopConcurrent behind enableThrottledScheduling, which is false in 19.3.1)13
Suspended on dataUnwind immediately to the nearest Suspense boundary / fallback (throwAndUnwindWorkLoop)If the thenable already resolved, replay the fiber. Otherwise attach onResolution, break outer (yield), and resume on ping14
Can return mid-treeOnly when it switches to prerenderingYes, whenever Scheduler says yield
JavaScript
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function workLoopConcurrentByScheduler() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

15

shouldYield is Scheduler's shouldYieldToHost. It returns true once about frameYieldMs = 5ms have passed since the current Scheduler task started, or right away if requestPaint() was called.16 So "time slicing" in practice means checking a clock between fibers, never inside a component. A single component that takes 200ms will still block for 200ms.

prepareFreshStack + createWorkInProgress for the root

prepareFreshStack is how a render starts over from scratch:

JavaScript
// simplified
function prepareFreshStack(root, lanes) {
  // cancel a pending timeout-commit or suspended commit, if any
  // ...
  resetWorkInProgressStack();          // unwind any half-finished WIP
  workInProgressRoot = root;
  const rootWorkInProgress = createWorkInProgress(root.current, null);
  workInProgress = rootWorkInProgress;
  workInProgressRootRenderLanes = lanes;
  workInProgressRootExitStatus = RootInProgress;
  // ... reset ~15 other module-level "workInProgressRoot*" variables ...
  entangledRenderLanes = getEntangledLanes(root, lanes);
  finishQueueingConcurrentUpdates();   // move interleaved updates onto fibers
  return rootWorkInProgress;
}

17

createWorkInProgress(current, pendingProps) is the double-buffering primitive (Fiber):

  • If current.alternate === null (true for the HostRoot on the very first render), it allocates a new fiber and links wip.alternate = current; current.alternate = wip.
  • Otherwise it reuses the alternate object: it resets flags (keeping only StaticMask), subtreeFlags and deletions, and copies child, memoizedProps, memoizedState, updateQueue, lanes, childLanes, sibling, index and ref from current.18

The WIP root starts out with child = current.child. In other words it begins by pointing at the current children. Children are cloned lazily, only as beginWork reaches them, either through reconciliation or through cloneChildFibers on a bailout (beginWork & Reconciliation).

The (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) check is what makes interruption work. If a transition render yields and a click arrives, the next performWorkOnRoot call gets SyncLane. The lanes differ, so prepareFreshStack runs resetWorkInProgressStack() (which calls unwindInterruptedWork up the return path to pop contexts) and starts a new WIP from root.current.19 The half-built transition tree is simply discarded. The transition's updates are still in the queues (they were never removed, see Hooks on Update), so it is re-rendered from scratch later on top of the new state.

performUnitOfWork → beginWork / completeUnitOfWork

JavaScript
function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;     // null => this fiber is mounting
  let next = beginWork(current, unitOfWork, entangledRenderLanes);
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    completeUnitOfWork(unitOfWork);         // no child: go back up
  } else {
    workInProgress = next;                  // descend into the child
  }
}

20

Notes:

  • current is the mount/update switch for the whole render phase. current === null means "this fiber has never been committed", and every beginWork/completeWork branch tests it. On the first render the HostRoot does have a current (created by createRoot, see createRoot), but every fiber below it is brand new, so current === null all the way down. That is why the first render is a "mount" everywhere except at the root. Page 07 explains why this means only the root's direct child gets a Placement flag.
  • memoizedProps = pendingProps happens right after beginWork. Next render, current.memoizedProps is what beginWork compares by identity against the new pendingProps to decide on a bailout.
  • beginWork returns the first child to work on next, or null.

completeUnitOfWork is the "climb back up" half:

JavaScript
function completeUnitOfWork(unitOfWork) {
  let completedWork = unitOfWork;
  do {
    if ((completedWork.flags & Incomplete) !== NoFlags) {
      unwindUnitOfWork(completedWork, workInProgressRootDidSkipSuspendedSiblings);
      return;                                   // something threw below
    }
    const next = completeWork(completedWork.alternate, completedWork, entangledRenderLanes);
    if (next !== null) { workInProgress = next; return; } // rare: spawned work
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) { workInProgress = siblingFiber; return; }
    completedWork = completedWork.return;       // no sibling: complete parent
    workInProgress = completedWork;
  } while (completedWork !== null);
  if (workInProgressRootExitStatus === RootInProgress) {
    workInProgressRootExitStatus = RootCompleted;
  }
}

21

So the traversal is an iterative depth-first walk that uses three pointers per fiber (child, sibling, return) instead of the JS call stack. React used to recurse (the pre-Fiber "stack reconciler"). This design is what makes yielding possible: the entire "stack" is a single variable, workInProgress, and you can stop after any fiber and come back later.

Depth-first traversal on a small tree

JSX
function App() {
  return (
    <div>
      <h1>Hi</h1>
      <List />
    </div>
  );
}
function List() { return <ul><li>a</li><li>b</li></ul>; }

Order of calls (B = beginWork, C = completeWork):

#CallWhy
1B(HostRoot)processes the update queue → {element: <App/>}
2B(App)runs App() via renderWithHooks
3B(div)reconciles [h1, List]
4B(h1)text child "Hi" is set via textContent, no fiber → returns null
5C(h1)creates <h1> DOM node. Has sibling → go to List
6B(List)runs List()
7B(ul)reconciles [li, li]
8B(li a) → C(li a)leaf. sibling → li b
9B(li b) → C(li b)no sibling → return to ul
10C(ul)creates <ul>, appends both <li>
11C(List)no sibling → return
12C(div)creates <div>, appends <h1> and <ul>
13C(App), C(HostRoot)bubbling only; workInProgress = null → RootCompleted

Every parent completes after all its children, which is why the DOM can be built bottom-up off-screen (completeWork).

First render vs update, in the loop

First render (mount)Update (e.g. setState in List)
HostRoot WIPcreateWorkInProgress allocates the alternatereuses the alternate
Fibers below rootall new (current === null)mostly cloned; untouched subtrees skipped
Fibers visitedevery fiberonly the path root → List plus List's subtree. App, div and h1 bail out because their lanes don't include the render lane, and childLanes tells React where to descend22
completeWork on hostscreates DOM nodesmarks Update if props object changed

The update case is the big performance property: a setState deep in the tree does not re-render from the root. beginWork runs on the ancestors, sees they have no work of their own (checkScheduledUpdateOrContext is false), and bailoutOnAlreadyFinishedWork just clones the child pointers down the childLanes trail. Details are in beginWork & Reconciliation.

Interruption, resumption and suspension (brief)

  • Yield: workLoopConcurrentByScheduler exits with workInProgress !== null → renderRootConcurrent returns RootInProgress → the Scheduler task returns a continuation. On the next task, workInProgressRoot === root && lanes match, so there is no prepareFreshStack and the loop simply resumes at workInProgress.10
  • Interrupt: higher-priority lanes arrive → lanes mismatch → prepareFreshStack → the old WIP is thrown away (see above).
  • Suspend: a component calls use(promise) or throws a promise. That surfaces as a throw out of beginWork, caught by the try/catch in the render function and handed to handleThrow, which records a workInProgressSuspendedReason (e.g. SuspendedOnImmediate / SuspendedOnData).23 In a concurrent render, React can wait for the promise without unwinding (thenable.then(onResolution, onResolution); break outer) and later replay only that one fiber (replaySuspendedUnitOfWork) instead of re-rendering the subtree.14 In a sync render it unwinds immediately to the nearest Suspense boundary, which shows its fallback.

Finishing → commit

When completeUnitOfWork climbs past the HostRoot, workInProgress becomes null and the exit status becomes RootCompleted. performWorkOnRoot reads finishedWork = root.current.alternate, which is the finished WIP root. It calls finishConcurrentRender → completeRootWhenReady → completeRoot → commitRoot.478 Despite its name, finishConcurrentRender is called for sync renders too.

At this point the WIP tree is complete. It has DOM nodes for new host fibers, flags on every fiber that needs a side effect, and subtreeFlags bubbled up so the commit can skip clean subtrees. Nothing has been inserted into the document yet. That happens in Commit Phase.

With the React Compiler

The work loop is unchanged. The compiler rewrites your component bodies and never touches react-reconciler (React Compiler). What changes is how many fibers the loop actually processes. Compiled components return cached JSX elements when their inputs haven't changed. As described on page 07, beginWork then sees oldProps === newProps for the children and bails out of whole subtrees, so in step "Fibers visited" above, the subtree under an updated component often shrinks to just the parts whose inputs really changed. Fewer performUnitOfWork calls means less render time and fewer yields. See With vs Without Compiler.

Summary

The render phase is a while loop over one global pointer, workInProgress. performWorkOnRoot decides whether to time-slice, prepareFreshStack clones the HostRoot into a work-in-progress fiber, and performUnitOfWork walks the tree depth-first: beginWork going down, completeWork coming back up. Because the whole "stack" is one variable and nothing touches the screen, React can yield, resume, or discard a half-built tree. Render can run several times and never commit, so it must be pure. Next: beginWork.

Key takeaways

  • shouldTimeSlice: DefaultLane is blocking, so the initial mount runs in renderRootSync; only Transition, Retry, Idle and Offscreen lanes yield.
  • Exit handling: performWorkOnRoot handles yields, re-renders synchronously if a concurrent render saw a store change (tearing), retries errors once, or calls finishConcurrentRender.
  • workLoopSync vs workLoopConcurrentByScheduler: the concurrent loop checks shouldYield() between fibers, never inside a component.
  • prepareFreshStack: resets render state, creates the root WIP and drains interleaved updates onto fibers.
  • Interruption: when the root or lanes change, the old WIP is discarded; its updates are still in the queues.
  • performUnitOfWork: current === null is the mount/update switch, and memoizedProps = pendingProps sets up the next bailout check.
  • completeUnitOfWork: completes a fiber, then moves to its sibling or parent, so parents complete after their children.
  • Updates skip work: childLanes limits a deep setState to the path to the updated fiber plus its subtree.
  • Suspension: a concurrent render can wait and replay one fiber; a sync render unwinds to the nearest Suspense boundary.

← Fiber · Index · beginWork & Reconciliation →

Footnotes

  1. scheduleTaskForRootDuringMicrotask sync branch — packages/react-reconciler/src/ReactFiberRootScheduler.js#L442-L456 (opens in a new tab) ↩

  2. performSyncWorkOnRoot — packages/react-reconciler/src/ReactFiberRootScheduler.js#L608-L622 (opens in a new tab) ↩

  3. performWorkOnRootViaSchedulerTask — packages/react-reconciler/src/ReactFiberRootScheduler.js#L513-L606 (opens in a new tab) ↩

  4. performWorkOnRoot (shouldTimeSlice at L1168-L1181) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1137-L1325 (opens in a new tab) ↩ ↩2 ↩3 ↩4

  5. includesBlockingLane — packages/react-reconciler/src/ReactFiberLane.js#L684-L694 (opens in a new tab) ↩

  6. store-consistency check and sync re-render — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1222-L1243 (opens in a new tab) ↩

  7. finishConcurrentRender — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1420-L1576 (opens in a new tab) ↩ ↩2

  8. completeRootWhenReady — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1578-L1701 (opens in a new tab) ↩ ↩2

  9. renderRootSync fresh-stack check — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2635-L2654 (opens in a new tab) ↩

  10. renderRootConcurrent fresh-stack check / resume — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2787-L2814 (opens in a new tab) ↩ ↩2

  11. renderRootSync — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2623-L2768 (opens in a new tab) ↩

  12. renderRootConcurrent loop selection and exit — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3007-L3053 (opens in a new tab) ↩

  13. enableThrottledScheduling = false, enableViewTransition = true — packages/shared/ReactFeatureFlags.js#L76-L90 (opens in a new tab) ↩

  14. renderRootConcurrent suspended-reason switch (replay / wait on thenable) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2820-L3005 (opens in a new tab) ↩ ↩2

  15. workLoopSync, workLoopConcurrent, workLoopConcurrentByScheduler — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2772-L2777 (opens in a new tab) and #L3056-L3079 (opens in a new tab) ↩ ↩2

  16. shouldYieldToHost / frameInterval — packages/scheduler/src/forks/Scheduler.js#L456-L472 (opens in a new tab); frameYieldMs = 5 — packages/scheduler/src/SchedulerFeatureFlags.js#L11 (opens in a new tab) ↩

  17. prepareFreshStack (state reset at L2254-L2284) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2023-L2293 (opens in a new tab) ↩

  18. createWorkInProgress — packages/react-reconciler/src/ReactFiber.js#L323-L437 (opens in a new tab) ↩

  19. resetWorkInProgressStack — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1979-L2002 (opens in a new tab) ↩

  20. performUnitOfWork — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3081-L3123 (opens in a new tab) ↩

  21. completeUnitOfWork — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3368-L3431 (opens in a new tab) ↩

  22. bailoutOnAlreadyFinishedWork — packages/react-reconciler/src/ReactFiberBeginWork.js#L3826-L3865 (opens in a new tab) ↩

  23. handleThrow — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2302-L2430 (opens in a new tab) ↩