Skip to main content

Time Slicing

What you'll learn

  • The core trick: split render work into small units and pause every ~5ms
  • Why React can pause mid-render, and ordinary function calls can't
  • How the pause actually works in the browser: MessageChannel
  • The one phase that must never pause: commit
  • What it means that a render can span many frames, or never finish at all

The last chapter ended with a question: must 800ms of render work happen as one uninterruptible block? This chapter answers it: no. React can do 5ms of work, hand the thread back to the browser, then pick up exactly where it left off. That technique has a name, and once you see the machinery, it's surprisingly simple.

The idea: one component at a time

Jargon: "time slicing". Breaking one big computation into small timed slices, yielding to the browser between slices so painting and input can happen. Same total work, spread over many breaths instead of one held breath.

Here's the key realization about what "rendering" actually is. Rendering a tree of 5,000 components is not one indivisible operation, it's thousands of tiny ones: call this component, compare these props, create these elements, move to the next node. Each tiny step takes microseconds.

Jargon: "unit of work". One small step of a render, roughly "process one node of the internal tree". React's render is a loop over units of work, not one giant function call.

So instead of running all units in one go, React runs a loop that checks the clock:

Pseudocode model, not real source:

// The work loop, conceptually
let workInProgress = firstUnit; // a pointer into the internal tree

function workLoop() {
const sliceStart = performance.now();
while (workInProgress !== null && performance.now() - sliceStart < 5) {
workInProgress = doOneUnit(workInProgress); // returns the next unit
}
if (workInProgress !== null) {
yieldToBrowserThen(workLoop); // work left: continue later
} else {
commitEverything(); // render finished: apply it in one go
}
}

What happens:

  1. A render is scheduled. workLoop starts and notes the current time.
  2. Each iteration completes one unit, roughly one component, and moves the pointer forward.
  3. After ~5ms, the loop stops even though work remains.
  4. The browser gets the thread: it can paint, and it can run any queued input handlers.
  5. The continuation fires, and workLoop resumes from the saved pointer, not from the top of the tree.
  6. When no units remain, the commit runs as one uninterrupted block.

The 800ms freeze from last chapter becomes ~160 slices of 5ms, with the browser breathing between each pair. Total CPU time: still ~800ms. Experienced freeze: zero.

Why React can pause (and your code can't)

Pause a normal JavaScript program halfway through a chain of function calls? Impossible. "Where you are" lives on the call stack, and there's no way to save a half-built call stack and restore it later.

React sidesteps this deliberately. Remember the fiber tree from the engine chapters: each node in React's internal tree knows its parent, its first child, and its next sibling, plain links between plain objects. Walking the tree doesn't require nested function calls; it's a loop following pointers.

And that's what makes pausing trivial:

  • "Where are we?" = one saved pointer to the current node.
  • Pause = keep the pointer, stop the loop.
  • Resume = start the loop again from that pointer.

The call stack gets thrown away between slices; the linked tree holds all the progress. This is one of the deepest reasons the fiber design exists at all, a tree you can walk with pointers is a tree you can pause.

The yield: posting a message to yourself

How do you actually "give the thread back and continue later" in a browser? You need to schedule a macrotask, the event-loop prerequisites chapter covered this: microtasks (promise callbacks) all run before the browser paints, so they can't be used to yield. You need something that lets pending input and paint go first.

The candidates:

  • setTimeout(fn, 0), works, but browsers clamp nested timers: after a few rounds, each setTimeout(0) silently becomes ~4ms. Yield every 4ms and you waste a quarter of every 60fps frame doing nothing.
  • MessageChannel, post a message to yourself; the delivery is a macrotask with no clamp. The browser can slot input and paint in between, and your continuation runs promptly.

Jargon: "MessageChannel". A browser API creating two connected ports; a message posted on one is delivered to the other as a macrotask. React uses it as a cheap "call me back as soon as the browser has breathed" doorbell.

You can build the whole pattern yourself, no React involved:

// Run this in a browser console: 100,000 items of work, no freeze.
const items = Array.from({ length: 100000 }, (_, i) => i);
const channel = new MessageChannel();
let index = 0;

function processOne(item) {
// Pretend: render one component. Real work would go here.
}

function workLoop() {
const sliceStart = performance.now();
while (index < items.length && performance.now() - sliceStart < 5) {
processOne(items[index]);
index++;
}
if (index < items.length) {
channel.port2.postMessage('continue'); // yield, then resume
} else {
console.log('done — and the page stayed responsive the whole time');
}
}

channel.port1.onmessage = workLoop;
workLoop();

What happens:

  1. workLoop processes items until 5ms have passed.
  2. Instead of looping on, it posts 'continue' and returns, the thread is free.
  3. The browser handles anything pending: scrolls, keystrokes, paints.
  4. The message arrives, onmessage calls workLoop, and processing resumes at index, exactly where it stopped.
  5. Repeat until all 100,000 items are done. Try scrolling or typing while it runs: perfectly smooth.

That is time slicing, in twenty lines of plain JavaScript. React's version is the same idea with a much fancier processOne.

Why 5ms: and why not "one frame"?

Three reasons for the specific number:

  • It must be small. ~50ms is the "feels instant" threshold for input. A 5ms slice means the browser is never more than a few milliseconds away from answering a keystroke.
  • It must not be too small. Every yield costs a little overhead (a message round-trip). At 1ms slices, the overhead starts to matter.
  • It is deliberately not frame-aligned. Frames vary, 16.6ms at 60Hz, 8.3ms at 120Hz. React's goal is answering input quickly, not syncing to a refresh rate, so it uses a fixed time budget and lets the browser paint whenever it gets a chance.

One honest limit: time slicing happens between units of work, never inside one. A single component that burns 800ms in its own function body still blocks, no scheduler can interrupt your synchronous code. Slicing helps when the cost is spread across many components.

What never yields: the commit

The render phase can pause 160 times. The commit phase, where React applies the finished work to the real DOM, runs as one uninterruptible block, always.

Why? Render is invisible: it only builds and compares objects in memory, so a half-rendered tree harms no one. Commit is visible: it moves real DOM nodes, sets real text, attaches real refs. A commit paused halfway would show the user a literally half-applied screen, the button moved but its label didn't. Some things must be atomic, and showing UI to a human is one of them.

Jargon: "commit phase". The short, synchronous phase after a successful render where React writes all computed changes to the host environment (the DOM) in one burst. Never interrupted, never time-sliced.

The strange new life of a render

Time slicing changes what "a render" even is:

  • A render may span many browser frames. Our 800ms list render takes ~160 slices; the old screen stays fully visible and interactive the whole time, because nothing commits until the last unit finishes.
  • A render may be thrown away entirely. If something more urgent arrives mid-render, React can discard the half-built draft and start over with newer state. That's not a bug, it's the whole point, and the next two chapters are about it.

A good mental image: a chef chopping vegetables who pauses mid-onion to answer the door, then resumes exactly where the knife was. The cutting board (the internal tree) holds all the state; the knife's position (the work pointer) marks the progress. The chef doesn't restart the recipe after every interruption, and if a VIP order comes in, the half-chopped draft can be scrapped without anyone ever tasting it.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: Time slicing makes rendering faster. Reality: total CPU time is the same or slightly more (yield overhead). What improves is responsiveness during the work.
  • Misconception: React can pause in the middle of my component. Reality: yields happen between units of work. One synchronous 800ms component still blocks the thread.
  • Misconception: setTimeout(fn, 0) would work just as well. Reality: nested timers get clamped to ~4ms, wasting a big slice of every frame; MessageChannel has no clamp.
  • Misconception: The screen shows half-rendered UI between slices. Reality: slices only build invisible objects. The screen changes only during the atomic commit.
  • Misconception: Promise callbacks (.then) could be used to yield. Reality: microtasks all run before the browser paints or handles input, yielding to a microtask yields to yourself.

Why it works this way

  • Linked tree = pausable traversal. Progress lives in pointers, not the call stack, so saving and resuming is free.
  • Macrotask yields = fairness. The browser gets real opportunities to paint and handle input between slices.
  • Invisible render, visible commit. Only invisible work is interruptible; visible work stays atomic so users never see a half-applied screen.
  • Fixed 5ms budget = device independence. Input latency stays low on any refresh rate, with negligible overhead.

Try it yourself

  1. Run the MessageChannel demo in a browser console and scroll the page while it works. Expected: perfectly smooth. Then replace the loop with one giant while over all 100,000 items. Expected: the page freezes until it finishes.
  2. Change the slice budget from 5ms to 50ms, keep an input focused on the page, and type while the loop runs. Expected: noticeable lag on every keystroke, you've rediscovered the 50ms threshold.
  3. Compare yields: rewrite the demo to reschedule with setTimeout(workLoop, 0) instead of the channel, log total elapsed time, then run the MessageChannel version. Expected: the setTimeout version takes noticeably longer, that's the 4ms clamp.

Recap

  • Time slicing: run units of work in ~5ms slices, yield to the browser between slices, resume from a saved pointer.
  • React can pause because its tree is walked via node links (parent / child / sibling), not the call stack, "where we are" is just one pointer.
  • The yield is a MessageChannel post: a macrotask with no 4ms timer clamp, so the browser can paint and handle input first.
  • The commit phase never yields, a half-applied DOM would be visible.
  • Renders may span many frames, and may be discarded entirely before committing.
  • Slicing splits work between components; one synchronous mega-component still blocks.

Next

Priority lanes →