Skip to main content

Concurrent rendering in React

Concurrent rendering in React is React 18's ability to pause a render mid-work, handle a more urgent update first, then resume or discard the paused work entirely.

Theory

TL;DR

  • Analogy: a chef who stops slow-roasting a dish to plate a walk-in order, then returns to the roast. React pauses low-priority renders when you type or click.
  • Before React 18, every render blocked the browser until it finished. Concurrent mode breaks work into ~5ms slices and yields to the browser between each one.
  • Main API: wrap slow state updates in startTransition. That covers 80% of real-world concurrent usage.
  • Decision rule: use it for heavy lists, search filters, or tab switching with complex views. Static pages don't need it.

Quick example

jsx
import { useState, useTransition } from 'react'; import { createRoot } from 'react-dom/client'; function Search({ items }) { const [query, setQuery] = useState(''); const [filtered, setFiltered] = useState(items); const [isPending, startTransition] = useTransition(); const handleChange = (e) => { setQuery(e.target.value); // urgent: updates the input right away startTransition(() => { // non-urgent: filtering 10k items won't block typing setFiltered(items.filter(i => i.includes(e.target.value))); }); }; return ( <div> <input value={query} onChange={handleChange} /> {isPending && <span>Updating list...</span>} <ul>{filtered.map((item, i) => <li key={i}>{item}</li>)}</ul> </div> ); } const root = createRoot(document.getElementById('root')); root.render(<Search items={Array(10000).fill('item')} />);

Without startTransition, typing in that input lags 100-500ms on a mid-range device. With it, the input responds instantly and the list catches up.

Synchronous vs concurrent rendering

Pre-18 React commits the entire component tree before returning control to the browser. Type a character while 10,000 items are rendering and the keystroke sits in a queue. React 18's scheduler breaks rendering into ~5ms chunks and checks between each chunk whether a higher-priority update arrived. If you typed again, the old render is discarded. React starts fresh with the latest value and commits only that result.

When to use

  • Search or filter over large datasets: startTransition keeps input responsive while the list updates in the background.
  • Tab switching with heavy views: mark the new tab's render as a transition so the current view doesn't freeze.
  • Rapid dropdowns or user profile switching: concurrent mode discards stale renders automatically, removing the need for manual request ID tracking.
  • Static pages or simple dashboards: synchronous rendering is fine. No overhead needed.
  • Legacy codebases: only add concurrent features where profiling shows actual jank. Check render durations in React DevTools Profiler first.

How it works internally

React 18 assigns every update a "lane," a 32-bit bitmask where lower bit values mean higher priority. A keystroke goes into InputContinuousLane. A startTransition update goes into TransitionLane. The scheduler runs a shouldYield() check roughly every 5ms, aligned to a 60fps frame budget. If a higher-priority update arrives mid-render, React stops building the current fiber tree, stores the half-built alternate tree in memory, and starts fresh. When the urgent work commits, React either resumes the paused work or garbage-collects it if state has changed since.

Nothing commits partially. The DOM always reflects the last completed render.

This runs in the browser's JS engine using a polyfill of requestIdleCallback. On the server, Node.js has no main thread that yields to input events. Use renderToPipeableStream with Suspense instead, which streams HTML chunks as data resolves.

Concurrent features at a glance

FeatureWhat it doesAPI
TransitionsMarks updates as interruptible and low-priorityuseTransition, startTransition
Deferred valuesDelays re-render of a derived valueuseDeferredValue
Automatic batchingGroups all state updates into one render, even in async codeBuilt into React 18
Streaming SSRSends HTML in chunks as Suspense boundaries resolveSuspense + renderToPipeableStream
Selective hydrationHydrates parts of the page independentlySuspense on client

Enabling concurrent rendering

jsx
// React 18: one line unlocks all concurrent features import { createRoot } from 'react-dom/client'; const root = createRoot(document.getElementById('root')); root.render(<App />); // Old API: stays fully synchronous // ReactDOM.render(<App />, document.getElementById('root'));

That one change is the gate. Automatic batching kicks in immediately. Each other feature (useTransition, useDeferredValue, Suspense) is opt-in per usage and won't affect code that doesn't use it.

Common mistakes

1. Wrapping trivial state in startTransition.

jsx
// no benefit: a counter update is already fast startTransition(() => setCount(count + 1));

Transitions add overhead: pending state tracking, interrupt handling, alternate tree work. On simple state, this can add 10-20% extra cost for zero visible gain. Profile before wrapping anything.

2. Ignoring isPending.

jsx
// wrong: user stares at a stale list with no feedback <input onChange={e => startTransition(() => setFilter(e.target.value))} />

During a transition, the old content stays visible. Without a spinner or loading indicator, users think the app is frozen. Always check isPending and show something.

3. Calling flushSync inside a loop.

jsx
// reverts to pre-18 blocking behavior items.forEach(item => flushSync(() => updateItem(item)));

flushSync forces a synchronous commit and bypasses the scheduler. In a loop, each iteration blocks the browser. Use it once after a loop if you genuinely need an immediate commit.

4. Assuming transitions run in arrival order.

Concurrent mode prioritizes by lane, not by the order startTransition was called. Two rapid transitions may result in only the second one committing. Design state updates to be idempotent and test with act in Jest to catch ordering issues.

The most common miss is number 2. Adding isPending after the fact is the fastest fix I've seen resolve a "why does my UI feel broken" bug in code that was otherwise correct.

Real-world usage

  • Next.js 13+ App Router enables concurrent rendering by default for React Server Components and streaming
  • TanStack Query's useSuspenseQuery integrates with Suspense boundaries and transitions for data fetch UX
  • React DevTools Profiler shows lane assignments and time slices so you can see exactly which renders get interrupted
  • Meta's production feed handles infinite scroll and live text input simultaneously using concurrent scheduling

Follow-up questions

Q: What is a "lane" in React's scheduler?
A: A 32-bit bitmask that encodes update priority. Lower bit values mean higher priority. InputContinuousLane beats TransitionLane. React groups updates with the same lane and flushes the highest-priority group first.

Q: How does startTransition differ from flushSync?
A: They are opposites. startTransition marks work as low-priority and interruptible. flushSync forces a synchronous commit before returning. Using both on the same update cancels out the transition.

Q: How long is a time slice in concurrent mode?
A: About 5ms, measured by Scheduler.unstable_shouldYield(). That fits within the 16ms frame budget for 60fps, leaving time for browser paint.

Q: What happens to a render that gets discarded?
A: The alternate fiber tree gets garbage collected. React never commits partial work, so the DOM always reflects a complete, consistent render.

Q: Why doesn't concurrent rendering apply to SSR the same way?
A: Node.js has no main thread that yields to input events. The server-side equivalent is renderToPipeableStream, which streams HTML chunks to the client as Suspense boundaries resolve.

Q: In a custom renderer without requestIdleCallback, how would you implement yielding?
A: Use setTimeout(0) with a priority queue and track frame budget via performance.now(). Hook into the Reconciler's scheduleCallback so React's own scheduler drives when work runs. Mentioning scheduleCallback from the Scheduler package specifically is what interviewers at this level want to hear.

Examples

Basic: createRoot setup

jsx
import { createRoot } from 'react-dom/client'; import App from './App'; const root = createRoot(document.getElementById('root')); root.render(<App />);

One line replaces ReactDOM.render. After this, React 18 automatic batching is active and useTransition becomes available anywhere in the tree.

Intermediate: todo search with loading feedback

jsx
import { useState, useTransition } from 'react'; function TodoSearch({ todos }) { const [query, setQuery] = useState(''); const [results, setResults] = useState(todos); const [isPending, startTransition] = useTransition(); const handleChange = (e) => { const value = e.target.value; setQuery(value); // always immediate startTransition(() => { setResults(todos.filter(todo => todo.text.includes(value))); }); }; return ( <div> <input value={query} onChange={handleChange} placeholder="Search todos..." /> {isPending ? <p>Filtering...</p> : null} <ul> {results.map(todo => <li key={todo.id}>{todo.text}</li>)} </ul> </div> ); }

isPending is true between the moment startTransition fires and when the filtered list commits to the DOM. That gap is your window to show a spinner, skeleton, or a text label.

Senior: rapid profile switching without stale data

jsx
import { useState, useTransition, Suspense } from 'react'; function UserProfile({ userId }) { const [profile, setProfile] = useState(null); const [isPending, startTransition] = useTransition(); const loadProfile = (id) => { startTransition(async () => { const data = await fetchProfile(id); setProfile(data); // if userId changed mid-fetch, React discards this render }); }; return ( <div style={{ opacity: isPending ? 0.6 : 1 }}> <Suspense fallback={<div>Loading profile...</div>}> {profile ? ( <ProfileCard data={profile} /> ) : ( <button onClick={() => loadProfile(userId)}>Load profile</button> )} </Suspense> </div> ); }

If a user clicks five different profiles rapidly, React discards the four intermediate renders and commits only the latest. Pre-18, you needed a manual "stale request" check: track the current request ID and ignore responses from outdated fetches. Concurrent mode removes that pattern for most cases.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?