React is single-threaded. Yet startTransition keeps your input responsive while rendering an expensive list. Here's how, and why it isn't multithreading.
The impossible demo
Here's a demo that shouldn't work:
function App() {
const [input, setInput] = useState('');
const [results, setResults] = useState([]);
function handleChange(e) {
setInput(e.target.value);
startTransition(() => {
setResults(heavyFilter(allItems, e.target.value));
});
}
return (
<>
<input value={input} onChange={handleChange} />
<ResultsList results={results} />
</>
);
}
heavyFilter takes 200ms. Without startTransition, every keystroke freezes the input for 200ms while React renders the new list. With it, the input stays responsive. You can type as fast as you want, and the list updates when React finds time.
But JavaScript is single-threaded. React can't render the list in the background while handling input events. There is no background. There's one thread, one call stack, one event loop.
So how?
The answer has been building across the entire series. The fiber tree (Part 5) made rendering interruptible. The lane model (Part 7) made updates prioritizable. Now the scheduler ties them together. React voluntarily pauses its own work to let the browser breathe, then picks up exactly where it left off.
The scheduler and cooperative yielding
React ships its own task scheduler, a standalone package called scheduler. It's independent of React's rendering logic and could theoretically be used by any framework. Its job is simple. Run callbacks, but yield to the browser periodically so the main thread stays responsive.
The core API:
// Simplified from Scheduler.js
function scheduleCallback(priorityLevel, callback) {
const task = {
callback,
priorityLevel,
expirationTime: currentTime + timeoutForPriority(priorityLevel),
};
push(taskQueue, task); // min-heap sorted by expiration
requestHostCallback(flushWork);
}
Tasks go into a min-heap sorted by expiration time. When the browser is idle, the scheduler pops tasks off the heap and runs them. The key part is that it checks between tasks whether it should yield.
The MessageChannel trick
How does the scheduler "yield to the browser"? It can't use setTimeout(fn, 0), because browsers clamp that to a minimum of 4ms, wasting time. It can't use requestAnimationFrame, which is tied to the display refresh rate and fires before paint, not after.
Instead, the scheduler uses a MessageChannel:
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = flushWork;
function requestHostCallback() {
port.postMessage(null); // schedules flushWork as a macrotask
}
postMessage schedules a macrotask with near-zero delay. This lets the browser process any pending input events, run microtasks, paint, and then come back to React's work. It's the fastest way to yield without wasting time.
Five lines of code. That's how React implements "cooperative multitasking", a concept that sounds like it belongs in an operating systems textbook, built on a browser API designed for iframe communication. Sometimes the cleverest solutions are the most unexpected.
The work loop and shouldYield
In Part 5, we saw the work loop that processes fibers one at a time. In concurrent mode, that loop has a crucial addition:
// Simplified from renderRootConcurrent
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
Compare this to the synchronous version:
// Simplified from renderRootSync
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
The only difference: && !shouldYield(). Six characters. That's the entire difference between synchronous React and concurrent React. Every other piece, fibers, lanes, the scheduler, exists to make those six characters work.
shouldYieldToHost checks how much time has elapsed since React started its current work slice:
// Simplified from Scheduler.js; frameYieldMs is declared in SchedulerFeatureFlags.js
const frameYieldMs = 5; // yield after 5ms
function shouldYieldToHost() {
const elapsed = getCurrentTime() - startTime;
return elapsed >= frameYieldMs;
}
That interval comes from SchedulerFeatureFlags.js, where frameYieldMs is set to 5, and the scheduler can adjust it at runtime for higher-refresh displays. Every ~5ms, React checks: "Have I been hogging the main thread?" If yes, it stops the work loop, lets the browser handle any pending events and paint, and resumes later via the MessageChannel.
Because the fiber tree stores the current position in workInProgress (not on the call stack), React can stop at any fiber and resume from exactly that point. The tree is its own bookmark, which was the entire purpose of the Fiber architecture.
Time slicing in action
Here's what happens when you type while a transition is rendering:
graph LR
subgraph Slice1["Work Slice (~5ms)"]
A["Render fiber 1"] --> B["Render fiber 2"] --> C["Render fiber 3"]
end
C -->|"shouldYield() = true"| Y1["⏸ Yield to browser"]
Y1 -->|"Browser processes\nkeypress event"| S["setState(input)\nSyncLane"]
S -->|"Higher priority!"| SYNC["Sync render:\nupdate input"]
SYNC --> Y2["Resume transition\nfrom fiber 4"]
subgraph Slice2["Work Slice (~5ms)"]
Y2 --> D["Render fiber 4"] --> E["Render fiber 5"]
end
- React starts rendering the transition (low-priority).
- After ~5ms,
shouldYield()returns true. React pauses. - The browser processes the queued keypress event.
- The event handler calls
setInput(), creating aSyncLaneupdate. - React processes the sync update immediately, so the input is updated.
- React resumes the transition render from where it left off.
The user sees the input update instantly. The list renders in the background, spread across multiple 5ms slices. If the user types again before the transition finishes, React discards the in-progress transition render and starts a new one with the latest value. No wasted work reaches the DOM.
Interruption and restart
What happens when you type while React is mid-render on a transition? React doesn't try to salvage the half-finished work. It throws it away and starts fresh with the new input value included.
This sounds wasteful, and it's safe, because the render phase is pure. It hasn't touched the DOM. It hasn't fired any effects. There's nothing to undo. The discarded work is just JavaScript objects that get garbage collected. All that "render phase must be pure" talk from Part 3? This is the payoff. Purity isn't a style preference. It's what makes interruption possible.
And from the user's perspective? They typed a character, the input updated instantly, and the list will catch up in a moment. They never saw anything stale or broken.
Suspense pauses a render by throwing a promise
Suspense looks like a simple component:
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
But its internal mechanism is unlike anything else in React, or, honestly, anything else in mainstream JavaScript. When a component inside a Suspense boundary needs data that isn't available yet, it throws a promise.
Yes, literally throws it. As in the keyword. Not returns. Not calls a callback. Throws. If your reaction to that is "wait, that's insane", you're in good company. But it works beautifully.
// Simplified. This is what a Suspense-compatible data library does
function fetchData(resource) {
if (resource.status === 'resolved') {
return resource.value;
}
throw resource.promise; // this is caught by React, not by try/catch
}
React's render phase catches this thrown promise in throwException:
// Simplified from throwException
function throwException(fiber, thrownValue) {
if (typeof thrownValue?.then === 'function') {
// It's a thenable, so this is a Suspense suspension
const suspenseBoundary = findNearestSuspenseBoundary(fiber);
suspenseBoundary.flags |= ShouldCapture;
thrownValue.then(
() => retryOnResolution(suspenseBoundary),
() => retryOnResolution(suspenseBoundary),
);
}
}
When the promise is caught:
- React finds the nearest
<Suspense>boundary up the fiber tree. - It marks the boundary to show the fallback instead of the suspended subtree.
- It attaches a listener to the promise. When it resolves, React schedules a re-render of the boundary.
- The commit phase shows the fallback (
<Spinner />). - When the data arrives and the promise resolves, React re-renders the subtree. This time the component doesn't throw. It returns elements normally.
The offscreen fiber
Suspense doesn't destroy the suspended subtree. It renders it into an Offscreen fiber, a special fiber type that keeps the work around but doesn't commit it to the DOM. When the data arrives, React can resume from the offscreen work rather than starting from scratch.
This is why Suspense feels fast even for complex subtrees. React may have already done most of the render work before the data arrived. It's pre-rendering, hidden behind a loading spinner.
useDeferredValue, where Suspense meets transitions
One more piece of the puzzle. If startTransition lets you schedule work at lower priority, useDeferredValue lets you keep showing old content while that work happens.
useDeferredValue creates a value that lags behind the current one, rendered in a transition lane:
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
return (
<Suspense fallback={<Skeleton />}>
<Results query={deferredQuery} />
</Suspense>
);
}
When query changes, React renders the component twice:
- Urgent render: Uses the new
queryfor the input, butdeferredQuerystill holds the old value. The UI updates immediately with the old results (no spinner). - Deferred render: In a transition lane, React re-renders with
deferredQueryupdated to the new value. IfResultssuspends, the old content stays visible instead of showing a fallback.
This is the key difference between useDeferredValue and a bare startTransition: the deferred value lets you keep showing stale content while fresh content loads, instead of showing a spinner.
You can think of it as startTransition but with a built-in "keep showing the old version while the new one loads" behavior. Under the hood, it's wired into the reconciler directly, with no extra state or effects needed.
Everything connects
This is the payoff of the series. Every concept we've covered was a building block for concurrent React:
| Concept | Article | Role in Concurrency |
|---|---|---|
| Hook linked list | Part 1 | State lives on fibers, survives interrupted renders |
| Effect synchronization | Part 2 | Effects run after commit, never during interruptible render |
| Render vs. commit | Part 3 | Render is pure and discardable; commit is synchronous |
| Event system | Part 4 | Events assign lanes: clicks get SyncLane, transitions get TransitionLane |
| Fiber tree | Part 5 | Iterative traversal allows pause/resume via workInProgress |
| Reconciliation | Part 6 | Flags accumulate during render, applied atomically in commit |
| Lanes | Part 7 | Bitmask priorities let React choose what to render first |
| Scheduler | This article | Cooperative yielding keeps the main thread responsive |
Concurrent React isn't a feature bolted onto the existing system. It's the reason the existing system was built the way it was. Fibers exist so rendering can be interrupted. Lanes exist so updates can be prioritized. The scheduler exists so React can share the thread with the browser. Remove any one piece and the whole thing collapses back into synchronous, blocking rendering.
The mental model, distilled
Concurrent React is cooperative multitasking on a single thread.
React splits its rendering work into small chunks (~5ms). Between chunks, it yields to the browser. If a higher-priority update arrives (a keystroke, a click), React handles it immediately, then resumes lower-priority work.
Suspense adds a second dimension: a component can pause its own render by throwing a promise. React shows a fallback, waits for the data, and retries, without the component managing any of that complexity.
None of this is multithreading. It's a single thread, carefully sharing its time between React's work and the browser's work. The illusion of concurrency comes from React being disciplined about when it works and how long it works before yielding.
graph TD A["User types"] --> B["SyncLane update\n(input)"] A --> C["TransitionLane update\n(results)"] B --> D["Sync render\n(immediate)"] C --> E["Concurrent render\n(~5ms slices)"] E -->|"shouldYield()"| F["Yield to browser"] F -->|"Browser idle"| E E -->|"Complete"| G["Commit to DOM"] D --> G
Series wrap-up
We started this series by asking why you can't call hooks in a conditional. We end by understanding how React renders a 10,000-item list without freezing your input field.
The thread that runs through all eight articles is this: React doesn't do what you think it does. Hooks aren't magic, they're a linked list. Effects aren't lifecycle methods, they're synchronization. Rendering doesn't touch the DOM, it produces a description. The virtual DOM isn't a copy of the real DOM, it's a work scheduler. And setState doesn't set state, it enqueues a prioritized update.
Every abstraction React gives you, useState, useEffect, Suspense, startTransition, is a thin layer over a mechanical system of linked lists, bitmasks, and iterative tree traversal. Once you see the machinery, the behavior stops being surprising.
I hope this series has replaced some mental hand-waving with concrete understanding. The next time React does something unexpected, you won't just Google the error message. You'll know which part of the machinery to look at.
And if you want to go even deeper, the source code is all on GitHub. Eight articles ago, that codebase was impenetrable. Now you know where to look, and more importantly, you know what you're looking at.