TL;DR / React and browsers assign different priority levels to updates -- user input is processed immediately while data fetches and offscreen work are deferred, allowing high-priority interactions to interrupt lower-priority rendering.
How It Works
┌──────────────┐ higher priority
│ User input │ Sync (highest)
└──────────────┘
│
│
↓
┌──────────────┐
│ Animation │ Continuous can interrupt
└──────────────┘
│
│
↓
┌──────────────┐
│ Data fetch │ Transition
└──────────────┘
│
│
↓
┌──────────────┐
│ Offscreen │ Idle (lowest)
└──────────────┘ lower priority
Modern UI frameworks and browsers process work at different priority levels because not all updates are equally urgent. A keystroke in a text input must be reflected within 16ms to feel responsive, while re-rendering a filtered list from a data fetch can take 100ms without the user noticing a delay. Priority scheduling exploits this asymmetry to keep the UI responsive under load.
Browser-Level Scheduling
The browser's event loop already implements implicit priority through task sources. User input events (click, keydown, pointermove) are dispatched from the user interaction task source, which browsers prioritize over other task sources. requestAnimationFrame callbacks run before paint. requestIdleCallback runs when the browser is idle. Microtasks (Promise callbacks, MutationObserver) run between macrotasks and before paint.
The Scheduler API (scheduler.postTask()) makes this explicit. It exposes three priority levels: user-blocking (highest -- input handlers, critical UI updates), user-visible (default -- rendering, data processing), and background (lowest -- analytics, prefetching). Tasks can be created with an AbortController for cancellation and a priority option. This API is available in Chromium-based browsers.
React's Priority Model
React 18's concurrent rendering introduces its own priority system built on top of browser scheduling. React categorizes updates into lanes, each with a different priority:
Sync lane: Discrete user events like clicks and keyboard input. These are processed synchronously and cannot be interrupted. React must finish this render before yielding to the browser.
Continuous lane: Continuous input like drag, scroll, and mouse move. These can be batched aggressively because intermediate values can be dropped without the user noticing.
Transition lane: Updates wrapped in startTransition(). These are explicitly marked as non-urgent. React can interrupt a transition render to process a higher-priority sync update, then restart or continue the transition render afterward. This is the primary mechanism for keeping input responsive while updating expensive derived views.
Idle/Offscreen lane: Work for components that are not currently visible (pre-rendering or offscreen preparation). This work is done only when no other work is pending.
How Interruption Works
When a transition render is in progress (rendering a large filtered list, for example) and the user types a character, React pauses the transition render at the next yield point (after the current component finishes rendering). It processes the sync update (the keystroke) immediately, commits it to the DOM, and then either restarts or continues the transition render with the updated state. The user sees their keystroke reflected instantly, even though the expensive re-render is still in progress.
This is only possible because concurrent rendering builds the new tree in memory without touching the DOM. The in-progress tree can be discarded or restarted without visual artifacts. Synchronous rendering cannot do this -- once it starts, it must complete before the browser can process any events.
Priority Inversion
Priority inversion occurs when a high-priority update depends on the result of a low-priority update. React handles some cases automatically -- if a transition update has been pending for too long (currently 5 seconds), React escalates it to sync priority to prevent starvation. But application-level priority inversion (where a button click's handler needs data from a transition that has not completed yet) must be handled by the developer, typically by showing a pending state.
Scheduler Interaction With Suspense
Suspense boundaries interact with the priority system. When a suspended component resolves, the re-render that displays the content is scheduled at the same priority as the update that triggered the suspension. If a transition triggered the suspension, the reveal render is also a transition and can be interrupted. If a sync event caused the suspension, the reveal is sync priority.
This means startTransition(() => navigate('/page')) keeps the current page visible and interactive while the new page's data loads, whereas navigate('/page') without a transition would immediately show the fallback, blocking user interaction.
Gotchas
startTransitiondoes not make the update slow -- it marks it as interruptible. If no higher-priority work arrives, the transition renders at full speed.- State updates inside
startTransitionare batched but may be restarted multiple times if interrupted. Avoid side effects in render functions, as they may execute more than once. useDeferredValuecreates a separate lower-priority copy of a value, causing the component to render twice: once with the current value (sync) and once with the deferred value (transition).- Priority starvation is possible if sync updates fire continuously (e.g., unthrottled scroll handlers updating state). Transitions never get a chance to complete.
- The
isPendingflag fromuseTransitionreflects whether the transition has committed, not whether it has started rendering. Use it for loading indicators, not for tracking render progress.