Concurrent Rendering
What you'll learn
- What "concurrent" actually means for React, and why it has nothing to do with threads
- The three ways a render attempt can end
- Why throwing away a half-finished render is safe, and what would make it unsafe
- What you may observe in practice: renders with no commit, components called multiple times
- What concurrency buys (responsiveness) and what it doesn't (speed)
The last two chapters built two facts: a render can be paused mid-way, and urgent work can preempt it. Put them together and something strange becomes possible: a render can be started, paused, and then thrown away without ever appearing on screen. That capability, interruptible, resumable, discardable rendering, is what "concurrent" means in React. This chapter makes it feel normal.
The one-sentence definition
Jargon: "concurrent rendering". Rendering in which the render phase can be interrupted, paused, resumed, restarted, or discarded entirely, while the last committed screen stays visible and interactive. One thread, interleaved work, not parallel threads.
Note what the definition does not say. There are no worker threads, no locks, no parallel DOM access. Everything still happens on the single main thread from chapter 1. "Concurrent" describes how render attempts overlap in time, several can be "in progress" (one rendering, several discarded) before one wins and commits.
The three outcomes of a render attempt
Every render React starts ends in exactly one of three ways:
- It completes → commit. All units of work finish, the result is consistent, and React swaps it onto the screen in the atomic commit phase. The happy path.
- It's preempted → discarded. A strictly-more-urgent update arrived mid-render (last chapter). The half-built draft is dropped, and React renders the urgent update first. The abandoned work may be restarted later, with newer state.
- It needs data → suspends. A component says "I can't render yet, I'm waiting on data." React either waits and resumes when the data arrives, or commits a fallback UI instead. That's the Suspense story, all of Part 5 is about it.
Outcomes 2 and 3 share a property that should make you slightly nervous: work was performed and then not used. Your component functions ran, maybe many times, for a screen that never existed. How is that not a disaster?
Why discarding is safe: render is pure
Go back to the foundations contract (Part 1): render is calling your function and collecting the element objects it returns. And the rule that makes that safe to do repeatedly: your component must be a pure function of its inputs, same props and state in, same elements out, with no observable effect on the outside world.
Purity is exactly what makes a render disposable:
- Calling your component twice and using the second result: fine, the first call changed nothing.
- Calling it ten times: still fine, just some wasted CPU.
- Calling it and never using the result: completely invisible to the world.
A discarded render is a pile of plain objects in memory. Nothing subscribed, nothing fetched, nothing mutated, nothing drawn. The garbage collector eats it and no one can prove it ever happened.
And this is why side effects in render are bugs waiting to fire twice
Flip it around. If your render touches the outside world, concurrent rendering turns that into an observable bug:
import { useState } from 'react';
let seenCount = 0;
function ProductCard({ title }) {
seenCount++; // side effect: mutates the outside world
fetch('/api/impression?title=' + title); // side effect: network request
return <h1>{title}</h1>;
}
export default function App() {
const [show, setShow] = useState(true);
return (
<div>
<button onClick={() => setShow((s) => !s)}>Toggle</button>
{show && <ProductCard title="Boots" />}
</div>
);
}
What happens:
- A render of
ProductCardstarts, and yourfetchfires immediately, during render. - That render gets preempted and discarded. The screen never shows it.
- Your analytics now contain an "impression" for a screen the user never saw, and
seenCounthas drifted from reality. - React may also restart the render, and your effect fires again for the same logical screen.
The fix you already know: side effects belong in useEffect, because effects fire at commit time, only for renders that actually made it to the screen.
import { useState, useEffect } from 'react';
function ProductCard({ title }) {
useEffect(() => {
fetch('/api/impression?title=' + title); // fires only when this UI commits
}, [title]);
return <h1>{title}</h1>;
}
export default function App() {
const [show, setShow] = useState(true);
return (
<div>
<button onClick={() => setShow((s) => !s)}>Toggle</button>
{show && <ProductCard title="Boots" />}
</div>
);
}
What happens: renders can now be discarded freely, the fetch only runs when a ProductCard actually commits. Impure render was always a latent bug; concurrency is simply the thing that makes it visible.
What you might observe
Once you know renders are discardable, some spooky-looking behaviors become mundane:
| What you observe | Why it happens | Is it a bug? |
|---|---|---|
console.log in a component fires, but the DOM and effects never show that state | That render was discarded before commit | No, normal |
| A component function runs 2–3 times before one commit | Render restarted after preemption | No, if render is pure |
| An effect fires exactly once per committed screen | Effects attach at commit, never during render | No, this is the guarantee |
| Total CPU ticks slightly up under heavy interaction | Abandoned renders get redone | No, the price of responsiveness |
The debugging rule of thumb: trust effects and the DOM, not render logs. Render output is a draft; commit is the publication.
What concurrency buys: and what it doesn't
Be precise about the win:
- It buys responsiveness. Urgent updates commit first, even when huge background renders exist. The app feels alive under load.
- It does not buy speed. The 800ms list still costs ~800ms of CPU. Add restart overhead, and concurrent mode can cost slightly more total CPU than synchronous rendering.
- It is not parallelism. One main thread, interleaved slices. If you imagined React farming components out to worker threads, no. The DOM is single-threaded, so the renderer is too.
The honest summary: concurrent rendering spends a little extra CPU to make sure the CPU is always working on what the user cares about right now.
Why the two-tree design makes all of this possible
Remember the two trees from the engine chapters: the current tree matches what's on screen, and the work-in-progress tree is a private draft built off to the side.
Concurrent rendering is that design, cashed in:
- The screen always shows the last committed tree, complete, consistent, untouched by in-progress work.
- A render attempt is just "build a draft". Pausing = keep the draft. Preempting = drop the draft and start a new one from the current tree plus the newest updates.
- Because the draft is private, discarding it can't corrupt anything. Because commit is an atomic pointer-swap-plus-mutations, the user never sees a half-rendered screen.
Without disposable drafts, interruption would be unthinkable, you'd risk leaving the visible tree half-rebuilt. The two-tree design is what makes "throw it away and start over" a cheap, safe move instead of a catastrophe.
Diagram
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: Concurrent rendering uses multiple threads. Reality: one main thread, interleaved slices. "Concurrent" describes overlapping render attempts, not parallel execution.
- Misconception: Concurrent mode makes my app faster. Reality: it makes it more responsive. Total CPU is the same or slightly higher; the win is ordering, not throughput.
- Misconception: If my component ran, the user saw it. Reality: render ≠ commit. Only committed renders reach the screen and fire effects.
- Misconception: A discarded render is wasted effort React should avoid. Reality: it's the deliberate cost of never blocking urgent work, and the alternative is a frozen UI.
- Misconception: Extra component calls mean something is broken. Reality: restarts are expected under concurrency; purity makes them harmless. (Development-mode double render is a separate teaching tool with the same moral: keep render pure.)
Why it works this way
- Purity turns renders into drafts. A pure function's output can be recomputed or ignored at will, that's what makes preemption cheap and safe.
- Commit atomicity keeps the screen consistent. Users only ever see complete, committed states, never a render in progress.
- Responsiveness beats throughput for human-facing UI. People forgive a background result arriving late; they do not forgive an app that ignores their clicks.
- Interleaving avoids the entire threading problem. One thread means no locks, no races, no partially-seen DOM, the platform's simplicity is preserved.
Try it yourself
- Add
console.log('render')to a component andconsole.log('effect')inside a matchinguseEffect, then interact with the app. Expected: render logs fire at least as often as effect logs, often more. Effects track commits; renders don't. - Run the
ProductCard"Bad" example with the render-timefetchand watch the Network panel while toggling. Expected: extra impression requests that don't correspond to visible screens. Move the fetch intouseEffectand confirm requests now match commits. - Thought experiment: your teammate logs analytics in render and says "it worked fine for years". Explain, in one sentence, what changed. (Answer: renders became interruptible and discardable, so "ran" no longer means "was seen".)
Recap
- Concurrent rendering = the render phase can be interrupted, paused, resumed, restarted, or discarded, all on one main thread.
- Every render attempt ends in one of three ways: commit, preemption (discarded), or suspension (waiting on data).
- Discarding is safe because render is pure: calling your component twice, ten times, or never using the result changes nothing observable.
- Side effects in render are the one thing that turns discarding into visible bugs, effects belong in
useEffect, which fires at commit. - Concurrency buys responsiveness, not speed; it is interleaving, not parallelism.
- The two-tree design makes it all possible: drafts are private and disposable, commits are atomic.