Skip to content

    Frontend System Design

    Fiber, Scheduling and Concurrent Rendering

    See how React splits rendering into pausable units, what transitions and deferred values change, and how to keep typing responsive while a long list updates.

    By Published 12 min read

    • frontend-system-design
    • react
    • concurrent-rendering
    • fiber
    • transitions
    Share

    Standard lessonadvancedInteractive simulatorFrontend System Design

    Before this: React's Render and Commit Phases

    What you'll learn: By the end of this lesson you will be able to explain what a fiber is, how React's work loop can pause, abandon and restart a render, how lanes give updates a priority, what useTransition, useDeferredValue and Suspense change about that, and which problems concurrent rendering does not solve.

    Picture a short-order kitchen with one cook. A customer orders a stew that takes an hour, and a second customer asks for a coffee. A kitchen that insists on finishing the stew first makes the coffee customer wait. A better kitchen prepares the stew on a side counter in small steps, makes the coffee when it is asked for, and puts the stew on a table only when it is complete. If the stew order changes halfway, the cook throws the half-made stew away and starts again.

    That is the shape of concurrent rendering in React. It is not parallelism: there is still one cook, the browser's main thread (see The Event Loop and the Main Thread). It arranges work so that urgent updates do not wait behind slow ones.

    The running example is a product search page: a text box and a list of 5,000 products (an illustrative size). Each row is slow enough that re-rendering the whole list on every keystroke makes typing stutter. At the time of writing, react.dev lists React 19.3 as the latest version. The site itself runs React 18.3, and everything here applies to 18 unless it says otherwise.

    The core idea

    1. Rendering and committing are different steps. Rendering is React calling your components to work out what the screen should show. Committing is applying the result to the DOM. React can pause or discard a render, because nothing is visible until the commit. (The lesson on render and commit phases is planned and not yet published.)
    2. A fiber is a unit of work. React turns your component tree into a tree of fiber objects and walks it one fiber at a time. Between fibers, the concurrent work loop can ask "should I stop now?"
    3. Priorities decide what to work on. Updates get a lane. A key press is urgent, a transition is not. React works on the urgent one first and may throw away non-urgent work in progress.

    The React 18 announcement says concurrency "is a new behind-the-scenes mechanism that enables React to prepare multiple versions of your UI at the same time". It is opt-in, "only enabled when you use a concurrent feature". Without one, updates render "in a single, uninterrupted, synchronous transaction". These features also need the createRoot API.

    Fiber: a unit of work you can pause

    In React's source (ReactFiber.js on the main branch at the time of writing), a fiber has, among other fields, type, key, stateNode, the tree pointers return, child and sibling, pendingProps, memoizedProps, memoizedState, lanes and alternate. The widely read architecture notes by a React team member call a fiber a "unit of work", and say they are not an official document and may be out of date.

    Figure 1. A fiber tree and its pointers

    flowchart TD accTitle: A small fiber tree accDescr: App has a child Search. Search has a child Input and a sibling Results. Each child points back to its parent through the return pointer. App["App fiber"] -->|"child"| Search["Search fiber"] Search -->|"child"| Input["Input fiber"] Input -->|"sibling"| Results["Results fiber"] Results -.->|"return"| Search Input -.->|"return"| Search Search -.->|"return"| App

    How to read this: child and sibling let the work loop walk down and across, and return lets it climb back up. React can stop after any fiber and resume from the pointers alone, with no JavaScript call stack to remember where it was.

    Each component instance has at most two fibers: the current one on screen and a work in progress linked through alternate. The source comment says React uses "a double buffering pooling technique because we know that we'll only ever need at most two versions of a tree." Rendering builds the new tree on the side, the kitchen's side counter, and the commit swaps it in. A function component's hook state hangs off its fiber's memoizedState; see How Hooks Work: State, Ordering and the Rules of Hooks.

    The work loop and the scheduler

    The reconciler has two loops. The synchronous workLoopSync is commented "Perform work without checking if we need to yield between fiber". The concurrent one is:

    // ReactFiberWorkLoop.js, main branch at the time of writing
    while (workInProgress !== null && !shouldYield()) {
      performUnitOfWork(workInProgress); // "Perform work until Scheduler asks us to yield."
    }

    performUnitOfWork processes one fiber. When shouldYield() returns true, the loop returns with the render unfinished, and the main thread can run input and paint before React comes back. This is the yielding of the event loop lesson, applied to rendering.

    shouldYield comes from the separate scheduler package. Its source says that by default it yields several times per frame. It schedules its own tasks with setImmediate where available, otherwise a MessageChannel, and uses setTimeout only outside browsers. The comment explains the choice: "We prefer MessageChannel because of the 4ms setTimeout clamping", the clamp described in the event loop lesson.

    Figure 2. From a set call to the screen

    A state update becomes a lane, a scheduled task, an interruptible render, then a commitSet call to Lane assigned; Lane assigned to Scheduler (schedule); Scheduler to Work loop (run task); Work loop to Scheduler (yield); Work loop to Commit (tree done)REACTSet call: click or transitionSet callclick ortransitionLane assigned: urgent or transitionLane assignedurgent ortransitionScheduler: MessageChannel taskSchedulerMessageChanneltaskWork loop: one fiber at a timeWork loopone fiber at atimeCommit: DOM changes at the endCommitDOM changes atthe endschedulerun taskyieldtree done

    How to read this: the loop and the scheduler trade turns. Each yield lets the browser handle input before the next scheduler task continues the render. Only a finished tree reaches the commit.

    Not every render yields. In performWorkOnRoot, time slicing is off for a forced synchronous render, for a blocking lane, and for lanes that have expired (the comment says this prevents starvation). In the source the blocking lanes are the hydration, continuous-input, default and gesture lanes. So an ordinary setState in a click handler is not time-sliced. Slicing is for transitions, deferred values and retries.

    Lanes: how updates get a priority

    A lane is a bit in a bit mask. The source defines SyncLane, DefaultLane, a group of TransitionLanes, RetryLanes, IdleLane and OffscreenLane, and picks the most urgent pending one with lanes & -lanes. Each queued update records its lane (the Update type in ReactFiberHooks.js has a lane field), and lanes expire, with shorter times for sync and continuous-input lanes than for default and transition lanes. The values live in feature flags, so this lesson quotes none.

    useTransition is how you give an update a transition lane. In the source, startTransition raises the priority of the pending flag, sets a transition context while your callback runs, and restores both afterwards.

    Interruption and restart

    When a higher-priority update arrives mid-render, React does not resume the old work. The comment in renderRootConcurrent reads: "If the root or lanes have changed, throw out the existing stack and prepare a fresh one. Otherwise we'll continue where we left off." The useTransition page says the same in visible terms: if you update a chart in a transition and type into an input mid-render, "React will restart the rendering work on the chart component after handling the input update."

    Figure 3. Typing interrupts a transition render

    sequenceDiagram accTitle: Typing during a transition render accDescr: The user types a, React renders and commits the input at urgent priority, then starts a transition render of the results. The user types b during that render, so React abandons the unfinished render, commits the input with ab, and restarts the results render for ab. participant U as User participant R as React participant L as Results render U->>R: types "a" (urgent) R->>R: render and commit the input R->>L: start transition render for "a" U->>R: types "b" while L is running R--xL: abandon the unfinished render R->>R: render and commit the input "ab" R->>L: restart from scratch for "ab" L-->>R: tree complete, commit results

    How to read this: the input is never behind the list. The price is that a list render may be wasted, so the work in it must be safe to repeat.

    What that means for your code:

    • Render must be pure. A render can run, be abandoned and run again before any commit. In development, Strict Mode double-calls components and updaters to expose impurity.
    • Effects run after the commit. The background re-render of a deferred value "does not fire Effects until it's committed to the screen".
    • The DOM stays consistent. React "waits to perform DOM mutations until the end, once the entire tree has been evaluated".
    • A component function cannot be paused. React yields between fibers, not inside one. This is inferred from the loop structure, not stated in the docs: one very slow component stays slow, and slicing helps only when work is spread across many fibers.

    Three tools, three situations

    Illustration 1, minimal: useTransition when you own the set call

    Split the search box into an urgent value (the input) and a non-urgent one (the list):

    function Search() {
      const [text, setText] = useState("");   // urgent: the input
      const [query, setQuery] = useState(""); // non-urgent: the list
      const [isPending, startTransition] = useTransition();
    
      function onChange(e: React.ChangeEvent<HTMLInputElement>) {
        setText(e.target.value);
        startTransition(() => setQuery(e.target.value));
      }
      return (
        <div>
          <input value={text} onChange={onChange} />
          <Results query={query} /> {/* Results is wrapped in memo */}
        </div>
      );
    }

    A test run under jsdom with React 19.3.1 (a DOM simulation, so it shows the order of renders, not timing) logged, after typing "a": Search renders: text="a" query="" isPending=true, then Search renders: text="a" query="a" isPending=false. The urgent render comes first, with the new text, the old list and a pending flag. The transition render follows. Per react.dev, isPending stays true "until all Actions complete and the final state is shown".

    Illustration 2, realistic: useDeferredValue when you only receive a value

    If the term arrives as a prop, you cannot wrap the set call. Defer the value instead:

    function Results({ query }: { query: string }) {
      const deferredQuery = useDeferredValue(query);
      return <SlowList text={deferredQuery} />; // SlowList is wrapped in memo
    }

    The docs: React "will first attempt a re-render with the old value", then "try another re-render in the background with the new value". The same jsdom run, with the parent holding query, logged App renders: query="ab" deferred="a" with SlowList not called, then App renders: query="ab" deferred="ab" and SlowList renders with "ab". The background render is interruptible, has "no fixed delay", and "does not by itself prevent extra network requests".

    Illustration 3, failure: no memo, no benefit

    "This optimization requires SlowList to be wrapped in memo." Without it, SlowList re-renders in the urgent pass with the old value, so typing stays slow. In the same jsdom run with memo removed, the log showed SlowList renders with "a" a second time before the render with "ab". Deferring never makes a render faster: "it tells React that re-rendering the list can be deprioritized".

    Figure 4. Which tool for which problem

    flowchart TD accTitle: Choosing between transition, deferred value and other fixes accDescr: If the slowness is one huge component, reduce the work. Otherwise, if you own the set call use a transition, and if you only receive the value use a deferred value with memo on the slow child. Q1{"Is the slowness
    one huge component?"} -->|"yes"| F["Reduce the work:
    virtualise, paginate, worker"] Q1 -->|"no, many fibers"| Q2{"Do you own the
    set call?"} Q2 -->|"yes"| T["useTransition or startTransition"] Q2 -->|"no, it is a prop or hook value"| D["useDeferredValue + memo on the slow child"] T --> N["Keep text input state urgent"] D --> N

    How to read this: concurrent features reorder work, they do not remove it. Ask first whether the work can be split, then pick the tool by what you control.

    Suspense, and external stores

    A Suspense boundary shows its fallback while its children are not ready. Whether an update that suspends shows it depends on the update's priority.

    Figure 5. What a boundary does when new content suspends

    stateDiagram-v2 accTitle: Suspense boundary during an update accDescr: A boundary showing content shows the fallback if an urgent update suspends, but keeps showing the old content with a pending flag if the update is a transition or deferred value, then shows the new content when ready. [*] --> ShowingContent ShowingContent --> ShowingFallback: urgent update suspends ShowingFallback --> ShowingContent: data ready ShowingContent --> OldContentPending: transition or deferred value suspends OldContentPending --> ShowingContent: data ready

    How to read this: the same data delay gives two experiences. On the lower path the user keeps the old screen, and isPending can dim it.

    The Suspense page: "React will only prevent unwanted fallbacks during non-urgent updates. It will not delay a render if it's the result of an urgent update." A transition "only waits long enough to avoid hiding already revealed content", and a newly rendered boundary still shows its fallback at once. Suspense activates for lazy components and for cached promises read with use, not for data fetched in an Effect. React 19 adds async functions in transitions ("Actions"); one documented limit remains: state updates after an await need another startTransition.

    Because a render can span several tasks, a mutable store could change under a half-finished render. useSyncExternalStore handles this: if a store changes during a transition, React "will fall back to performing that update as blocking".

    Trade-offs

    Approach What it gives What it costs
    Do nothing Simplest code Urgent updates wait behind slow renders
    useTransition Urgent and non-urgent split, isPending You must own the set call; extra state; wasted renders when interrupted
    useDeferredValue Works from a prop; no fixed delay Needs memo on the slow child; an extra background render
    Debounce Fewer renders A fixed delay for everyone; each render still blocks
    Virtualise or paginate Less work in total Layout and accessibility work

    In practice: a search page that drops keystrokes

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    A catalogue page filters 5,000 products (illustrative) as the user types. On the team's laptops it feels fine. Field data from mid-range phones shows poor Interaction to Next Paint on this page, and the slow part is the list render after each key press. Three engineers own the page.

    Starting architecture

    Figure 6. One urgent path for everything

    Starting architecture: the input and the list update in the same urgent renderKey press to One state value; One state value to Render all; Render all to CommitMAIN THREADKey press: input eventKey pressinput eventOne state value: queryOne statevaluequeryRender all: input and 5,000 rowsRender allinput and 5,000rowsCommit: DOM and paintCommitDOM and paint

    How to read this: the input cannot show the new letter until the whole list has rendered, because both share one urgent update.

    Options considered

    Option Fixes Costs Risk
    Debounce the query Fewer list renders A delay for everyone Each render is still slow
    Transition for the list query, memo on the list Input stays instant; list follows A second state value Wasted renders under fast typing
    useDeferredValue inside the list Same, without changing who owns state Needs memo Looks like a fix even when memo is missing
    Virtualise the list Far fewer rows rendered Layout and accessibility work Larger change

    The decision

    Field Decision record
    Context One urgent render updates the input and 5,000 rows, so typing waits for the list.
    Decision Keep the input state urgent, update the list's query in a transition, wrap the list in memo. Track virtualisation as a follow-up.
    Consequences, good Typing stays responsive; the list may lag slightly.
    Consequences, bad Two state values, a pending style, and renders that may be discarded.
    Revisit when Field INP stays above the "good" threshold: reduce the work (virtualise) instead of reordering it.

    Rollout and verification

    1. Record a profile with CPU throttling and check that the first urgent commit is short.
    2. Type quickly and confirm the list always settles on the final text.
    3. Ship behind a flag and compare field INP for both groups.

    The 2 a.m. failure

    A week later the page gains server-side suggestions in a Suspense boundary, read with use. Support reports that the results area flashes a spinner on every keystroke on slow networks. The query was passed straight to the suspending component in an urgent update, so React showed the fallback each time, as the Suspense docs warn.

    First three checks: (1) confirm the suspending component receives the urgent value; (2) look for use on a new promise each render (React 19 warns about uncached promises); (3) check that isPending is used somewhere. Mitigation: disable suggestions behind the flag. Follow-up: feed the suspending child a deferred value, and add a test that the fallback never appears after the first reveal.

    Architect's notes

    • Decide what is urgent before choosing an API. Input, focus and press feedback are urgent; results derived from them usually are not.
    • Find out whether the slow part is many fibers or one. Concurrent rendering helps the first and not the second.
    • Treat memo as part of the pattern, and keep renders pure.

    Common mistakes

    • Putting a controlled text input's state in a transition. The docs say transition updates "can't be used to control text inputs".
    • Expecting a transition to speed up a render. It reorders work.
    • Assuming a deferred value stops network requests. It defers display only.
    • Reading mutable external state during render instead of using useSyncExternalStore.

    Key takeaways

    • Concurrent rendering pauses, abandons and restarts renders; the commit still applies to the DOM at once.
    • A fiber is a unit of work; each component instance has a current and a work-in-progress fiber.
    • The concurrent loop checks shouldYield() between fibers; blocking lanes, such as ordinary click updates, are not time-sliced.
    • A higher-priority update makes React throw out the render in progress and start fresh.
    • useTransition splits updates you own; useDeferredValue does it for values you receive, and needs memo.
    • A transition or deferred value keeps already revealed content instead of a Suspense fallback; an urgent update does not.

    Practice exercises

    Exercise 1, Starter (30 min): Take a component with a slow child and an input. Add memo and useDeferredValue, log renders as in this lesson, predict the log, then compare.

    Exercise 2, Intermediate (2 hours): Build the search page with urgent and transition state. Record a throttled profile before and after. Then move the input's own state into the transition and describe what changes and why the docs advise against it.

    Exercise 3, Advanced (half day): Add a Suspense boundary reading a cached promise with use. Show the fallback flashing with an urgent update, fix it with a transition or deferred value, and write a test that the fallback is not shown after the first reveal.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments