Under the Hood
Reactinternals

Scheduling and concurrent React

Because fiber makes reconciliation interruptible, React can behave like a cooperative scheduler on the main thread — doing a chunk of rendering work, yielding so the browser can paint and handle input, then resuming — and that single mechanism is what every concurrent feature, from transitions to Suspense, is built on top of.

Scheduling and concurrent React

Fiber turned reconciliation from one uninterruptible recursive call into a loop over small units of work, pausable between any two of them. This lesson is about what React actually does with that ability. The short version: React acts as its own cooperative scheduler, sitting on top of the browser's single main thread, deciding — unit of work by unit of work — whether to keep rendering or hand control back so the browser can paint a frame and handle input. That decision loop is what "concurrent React" means, and it is React reimplementing, inside its own render phase, the exact chunk-and-yield idea the event loop module builds for every other kind of long-running work on the main thread.

The scheduler: yielding between units of work

Recall the work loop from the fiber lesson: beginWork on a fiber, descend to its child, and so on, checked after every single fiber. Because that check happens so often, React has a natural place to ask a second question beyond "what's the next unit of work": should I keep going right now, or should I stop and let the browser do something first?

That question is answered by React's own scheduler. Historically implemented with a MessageChannel-based loop (a way to post a genuine macrotask, the same task-boundary trick the scheduling lesson covers for setTimeout-based yielding), the scheduler tracks a frame budget — roughly how much time is left before the browser needs the thread back to keep hitting its render cadence — and after each unit of work, checks whether that budget has run out. If it has, React yields: the current task ends, the stack empties, and control returns to the event loop, which can now paint a pending frame or run a queued input handler. Whatever's left of the render is picked back up in a later task, continuing from exactly the fiber where it stopped, because the work-in-progress tree remembers where it left off.

This is cooperative multitasking, in the exact textbook sense: nothing forces React to yield — no OS-level preemption is involved — it chooses to check in, regularly, and hand the thread back voluntarily. It's the same discipline required of any long-running work on a single thread, just implemented inside React's own render loop instead of in application code.

Priorities and lanes: not all updates deserve equal urgency

A yield-when-budget-runs-out scheduler only gets you so far — it still finishes renders in the order they started. But not every update is equally urgent. A keystroke updating an input's value needs to feel instant; re-filtering a 50,000-row list in response to that same keystroke can visibly lag a beat without anyone minding, as long as the input itself doesn't stall. React represents this with priorities (internally, "lanes" — bits marking which category of update a piece of work belongs to). A low-priority render already in progress can be interrupted by a higher-priority update: React abandons or pauses the in-progress work-in-progress tree, processes the urgent update first, commits it, and only then goes back to the low-priority work — restarting it if needed.

This is only safe because of two things earlier lessons already established: render is interruptible (fiber, lesson 4) and render is pure — a component function is expected to produce the same output for the same inputs, with no side effects that would break if run twice (render and commit phases). Abandoning a half-built work-in-progress tree and starting over costs nothing observable, because nothing outside React was ever touched — the current tree on screen was never mutated. A scheduler that could interrupt work but not safely discard it would be useless; fiber's double-buffering is what makes "throw it away and redo it" free.

Concurrent features: all of them lean on the same two things

Every feature marketed as "concurrent React" is a user-facing API wrapped around interruptible, priority-aware rendering. None of them introduce a new mechanism — they're all ways of telling the scheduler which updates matter more.

useTransition marks a state update as low priority — a Transition — explicitly:

function SearchPage() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const [results, setResults] = useState([]);

  function handleChange(e) {
    setQuery(e.target.value); // urgent: keep the input responsive
    startTransition(() => {
      // marked non-urgent — React can interrupt this render
      // if the user types again before it finishes
      setResults(filterHugeList(e.target.value));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Updating…</span>}
      <ResultsList results={results} />
    </>
  );
}

query updates through an ordinary, high-priority setState — the input's displayed value commits immediately, every keystroke, no lag. results updates inside startTransition, so React treats re-rendering ResultsList as low priority: if the user types another character before that render finishes, React interrupts it and starts over with the newer query, rather than finishing a render for a query that's already stale. The input stays responsive throughout, because it was never waiting behind the expensive render in the first place — they're different priority lanes, and the urgent one always wins the race to commit.

useDeferredValue is the same idea shaped as a value instead of an update: const deferredQuery = useDeferredValue(query) gives you a version of query that's allowed to lag behind during expensive renders, updating to catch up once React has spare capacity — useful when you don't control the state update itself (it's coming from a parent or a library) but do control what's expensive to compute from it.

Automatic batching is the same lanes machinery applied to grouping: multiple setState calls that happen within the same event — even across async boundaries, in modern React — are collected and applied together, producing one re-render instead of one per call, because they share the same priority and the same task.

Suspense lets a component "suspend" — signal that it isn't ready yet (data still loading, code still downloading) — without breaking the render for everything around it. React catches the suspension, renders the nearest <Suspense fallback={...}> boundary's fallback in that component's place, keeps the rest of the tree responsive, and swaps the fallback for the real content once the suspended component's data resolves. It's built on the same interruptible-unit-of-work loop: suspending a fiber means React can walk away from that branch of the tree and come back to it later, exactly like a low-priority render being paused.

The throughline

Everything in this lesson depends on one property, established two lessons ago and never restated as a caveat since: rendering is a loop over small, interruptible units of work, over a tree that can be safely thrown away and rebuilt because the real DOM is never touched until commit. Priorities are just labels on that interruptibility. Transitions, deferred values, batching, and Suspense are just ergonomic APIs for expressing "how urgent is this" and "what should show while this isn't ready" on top of a mechanism that was already capable of pausing and resuming before any of those APIs existed. React's answer to "how do you keep a single main thread responsive while doing real rendering work on it" is not a different thread — it's the same discipline the event loop module spends its whole closing lesson on, implemented as a first-class part of the reconciler itself.

What this sets up

Interruptible rendering and priority explain when work happens. The last piece is how much work happens at all — what actually causes a component to re-render in the first place, and how to stop unnecessary ones from cascading through a tree. Re-renders and memoization closes out the module with exactly that.

Go deeper

  • React docs — useTransition The Transition API this lesson's example uses, including isPending and how startTransition marks work non-urgent.
  • React docs — useDeferredValue The value-lagging alternative to Transitions, for cases where you don't control the state update directly.
  • React source — the scheduler package The actual priority levels, frame-budget heuristics, and MessageChannel-based yielding this lesson describes at a mechanism level.
  • React blog — React 18 The release that introduced automatic batching, Transitions, and the concurrent rendering APIs this lesson covers, from React's own team.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. What question does React's scheduler ask after every unit of work, and what does it do when the answer is 'no more budget'?
  2. Why is this an instance of the event-loop module's chunk-and-yield pattern rather than something unrelated to it?
  3. What does it mean for an update to have higher priority than another, and what can a high-priority update do to an in-progress low-priority render?
  4. Why is it safe for React to discard a work-in-progress tree mid-render when a higher-priority update interrupts it?
  5. In the useTransition example, why does the input stay responsive even while ResultsList is re-rendering on a big list?
  6. What is the difference between useTransition and useDeferredValue in terms of what you're marking as non-urgent — an update, or a value?
  7. Is concurrent React running code in parallel on multiple threads? What is actually meant by 'concurrent' here?