startTransition doesn't defer — it tags updates as interruptible
Every senior React dev thinks `startTransition(fn)` defers `fn`. It doesn't — `fn` runs synchronously. What the wrapper actually marks is any state updates triggered inside, promoting them to the transition lane where the scheduler may interrupt and restart them. Here's the react.dev contract with two animations.
Three keystrokes, same list-render work, two schedules. Watch the "aborted" frames:
The startTransition API looks like a deferral primitive. The name suggests it schedules your work to run later. Most senior React devs read it as "set this aside, run it when there's time". That reading is wrong, in a way that changes how you write the code.
startTransition(fn) does not defer fn. fn runs immediately and synchronously, as if you hadn't wrapped it at all. What the wrapper does is tag every state update triggered inside fn's synchronous execution as a transition. That puts them in a low-priority lane in React's scheduler. Once there, they're eligible to be interrupted, paused, and restarted when something urgent arrives.
This post reads the react.dev contract and pins down what you're actually asking the scheduler to do. Two animations show the difference between "deferred" and "interruptible".
startTransition(fn) calls fn synchronously. State updates called during fn are marked as low-priority (transition) updates. Later, when React's scheduler is running those updates and an urgent update arrives (e.g. a keystroke), React will abandon the in-flight transition render and restart. The function passed to startTransition is never deferred; only the render that results from its state updates is interruptible.
What startTransition actually does
react.dev is explicit, in the troubleshooting section:
The function you pass to
startTransitionis called immediately with no parameters and marks all state updates scheduled synchronously during theactionfunction call as Transitions.
Two clauses, both load-bearing:
- "is called immediately" — no deferral, no microtask, no scheduler trip. The function runs inline on the stack where you called
startTransition. - "marks all state updates scheduled synchronously during the action function call" — the tagging is scoped to the synchronous portion of
fn. Anything that fires after anawaitor inside a.then()is already outside that scope and is not a transition.
The practical consequence is the most common bug I see with this API:
startTransition(() => {
fetch("/api/filter").then((r) => r.json()).then((data) => {
setResults(data); // ← NOT a transition; async boundary crossed
});
});The author thought wrapping the whole thing made the setResults call a transition. It doesn't — only the synchronous part of the callback runs inside the transition's scope. By the time .then fires, startTransition's tag is gone. setResults is an urgent update.
React 19's Actions API makes the pattern nicer — the action callback you pass to useTransition can be async — but per react.dev this is still a known limitation: any set call made after an await must be wrapped in its own startTransition for the update to remain marked as a transition. The mental rule holds: transitions mark synchronous state updates; async boundaries end the marking.
Step through the three-stage anatomy — runs synchronously, tags the setState calls, and the scheduler may later interrupt:
- —startTransition calls your function immediately, inline.
- —Any console.log inside prints now, before startTransition returns.
- —fn is NOT scheduled, deferred, or dispatched to a microtask.
startTransition(() => {
console.log("runs now"); // ← immediate
setFilter(newFilter); // ← state update
});
console.log("then this"); // ← after, syncThe interruption contract
The payoff for tagging state updates is in what the scheduler does with them:
A state update marked as a Transition will be interrupted by other state updates. For example, if you update a chart component inside a Transition, but then start typing into an input while the chart is in the middle of re-render, React will restart the rendering work on the chart component after handling the input update.
"Restart the rendering work" is the phrase that matters. React doesn't pause and resume. The work done so far is discarded and the render begins again with the newer state. If your transition's render built up a large fiber tree before the interrupt, that work is thrown away. There's no cached partial progress.
This is what the top canvas shows. With three keystrokes at t=0, t=380, t=760 and a 520ms list render:
- Without transition — each render is urgent. The second keystroke waits ~140ms for the first render to finish. The third keystroke waits ~260ms for the second. Input lag stacks.
- With transition — each keystroke immediately starts a new list render and aborts the previous one. The aborted render never commits. Input lag is ~0 — the typed value is always live.
That's the design. A transition's render is expected to be abandoned if faster signals keep coming in; you're opting into "best effort, if completed, otherwise throw it away."
isPending is when, not what
The second return from useTransition, isPending, tracks whether there's an active transition still in flight:
To give the user feedback about in-progress Transitions, the
isPendingstate switches totrueat the first call tostartTransition, and staystrueuntil all Actions complete and the final state is shown to the user.
Key detail: isPending is itself a normal (non-transition) state. Setting it is urgent, so the spinner shows up immediately — that's why useTransition is the primary way to show loading without layout shift, even when the actual content update is deferred. It's not that React defers your work; it's that React lets you show that it's happening at urgent priority while the actual render happens at transition priority.
useTransition vs useDeferredValue
Two hooks, same underlying lane mechanism, different ergonomics:
useTransition— you callstartTransition(() => setX(...))at the source of the update. The transition tag is applied at the update site. Best for "I'm about to do something expensive; mark the state change downstream".useDeferredValue— you callconst deferred = useDeferredValue(value)at the consumer. React gives you a value that may lag the real one by one render, and the render usingdeferredruns at transition priority. Best for "I don't control the source of the update, but I can slow down the consumer".
Both use the same concurrent-rendering infrastructure. Both produce interruptible, restart-able renders. Pick useTransition when you control setState, useDeferredValue when you don't.
When startTransition is the wrong tool
Three anti-patterns from real code review:
-
Wrapping a
fetchto "defer the request".startTransitiondoesn't defer the function. Thefetchfires immediately. If you want to debounce or defer the request itself, that's a different primitive (setTimeout, debounce,AbortController). -
Assuming the transition always completes. A busy page with constant urgent updates will interrupt your transition repeatedly. The render can starve —
isPendingstaystrueindefinitely because each attempt gets abandoned. If you need "eventually this will commit", you need to defend against the starvation yourself (debounce the urgent signal, or useuseDeferredValuewhich has its own throttling). -
Relying on transition as a correctness primitive. Transitions can be abandoned at any time. If your code requires the commit to fire to stay consistent (e.g., sending analytics), move that code outside the transition. The commit may never arrive.
The instinct that's right: "I want to show the user that something's happening, but I don't want their input to feel blocked." The instinct that gets you in trouble: "startTransition is like setTimeout(0) but better." They're not in the same category.
Primary sources
- react.dev —
useTransition— the canonical reference: synchronous execution offn, the "scheduled synchronously" tagging rule, the interruption contract. - react.dev —
startTransition— the non-hook form; same semantics, usable outside of components. - react.dev —
useDeferredValue— the consumer-side counterpart for when you don't control the update source. - react.dev — Concurrent React (under the hood) — the Suspense docs contain the clearest statement of concurrent rendering's lane-based scheduling.