The Work Loop and Time Slicing
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
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.
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).
performUnitOfWork: One Fiber at a Time
Each call to performUnitOfWork does two things:
- beginWork — Process the current fiber: call the component function, diff children, create child fibers
- 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;
}
}
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.
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.
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--|...
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
-
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
-
Update A (
setCount) is processed byworkLoopSync. It runs to completion without yielding. The counter updates immediately. -
Update B (
setListinsidestartTransition) is processed byworkLoopConcurrent. It yields every 5ms, allowing browser paint and event handling between chunks. -
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.
-
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
- 1The work loop processes one fiber per iteration, checking shouldYield() between each. This is how React achieves 5ms time slices.
- 2performUnitOfWork calls beginWork (render the component, create child fibers) then either descends to the child or completes and moves to a sibling.
- 3beginWork goes down the tree (child direction). completeWork goes up (return direction), creating DOM nodes and bubbling effect flags.
- 4shouldYield() uses a 5ms budget. After 5ms, React yields to the browser via MessageChannel, then resumes in the next macrotask.
- 5Only workLoopConcurrent checks shouldYield(). Synchronous renders (normal setState) run the entire tree without interruption.
- 6One component render is one unit of work. Time-slicing can't interrupt a single slow component — only the gaps between components.