React Deep-DivesApr 19, 2026·7 min read·

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:

no useTransition — urgent re-render per keystroke
Second keystroke waits for first render to finish.
max input lag: 280ms
a
b
c
render "a"
render "ab"
render "abc"
startTransition(() => setQuery(…))
New keystroke interrupts the previous list render.
max input lag: 0ms
a
b
c
render "a"aborted
render "ab"aborted
render "abc"
clock · 0mskeystrokes at 0ms, 380ms, 760ms · each list render: 520ms
"aborted" frames are the ones React discarded when an urgent update arrived.

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".

tl;dr

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 startTransition is called immediately with no parameters and marks all state updates scheduled synchronously during the action function call as Transitions.

Two clauses, both load-bearing:

  1. "is called immediately" — no deferral, no microtask, no scheduler trip. The function runs inline on the stack where you called startTransition.
  2. "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 an await or 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:

1. fn runs synchronously
  • 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, sync
Auto-cycling. Click any step to pause.

The 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 isPending state switches to true at the first call to startTransition, and stays true until 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 call startTransition(() => 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 call const deferred = useDeferredValue(value) at the consumer. React gives you a value that may lag the real one by one render, and the render using deferred runs 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:

  1. Wrapping a fetch to "defer the request". startTransition doesn't defer the function. The fetch fires immediately. If you want to debounce or defer the request itself, that's a different primitive (setTimeout, debounce, AbortController).

  2. Assuming the transition always completes. A busy page with constant urgent updates will interrupt your transition repeatedly. The render can starve — isPending stays true indefinitely 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 use useDeferredValue which has its own throttling).

  3. 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