Concepts — mini-react II: Batching, Scheduling, and Deriving Fiber

Phase 4, after bi-11 · Spec area §27 stages 8–11. Hard prerequisites: fw-02, bi-11 (Blink scheduling), bi-10 (frame budget).

This module exists separately because deriving Fiber before you have felt the frame budget produces transcription, not understanding. §45 is explicit: "Do not begin with production source. First derive a simple design."


0. Orientation

The 60-second version

One sentence generates this entire module:

Recursion is the enemy of interruptibility.

A recursive renderer stores its progress in the call stack, and the call stack is owned by the language runtime. You cannot inspect it, suspend it, discard it without unwinding, or hold two of them at once. Interruptible rendering needs all four verbs. Therefore the stack must stop being the runtime's and become a data structure you own — nodes with child / sibling / return pointers, walked by a loop.

Everything Fiber-shaped follows from that, and you should be able to derive each step rather than memorise it:

recursive render
   → try to pause it            → you can't; the state is on the stack
   → make the stack explicit    → child / sibling / return pointers, walked by a loop
   → now you can yield          → loop while work remains AND time remains
   → so you need two phases     → interruptible work must not touch the DOM
   → so you need double buffering → build the new tree while the old one is displayed
   → so you can prioritise      → high-priority work abandons low-priority work in flight
   → so render must be pure     → discardable work cannot have had effects
   → and batching falls out     → rendering is scheduled, so N setStates coalesce

Why this is a separate module from fw-02

Deliberately, and the reason is methodological: deriving Fiber before you have felt the frame budget produces transcription, not understanding. You need bi-11 (Blink scheduling) and bi-10 (frame budget) first, so that "yield" means a measured constraint rather than a word.

The honest part

Time-slicing makes a long render interruptible. It does not make it shorter — total wall-clock goes up slightly from scheduling overhead. It helps only when the long work is in the render phase, and effects and layout do not yield.

"Concurrent React fixes our INP" is true only when input delay is caused by long render phases. It is false, and an expensive distraction, when the long tasks are effects, data processing, or getBoundingClientRect loops. One diagnostic question settles it: is the long task a render, or something else?

What you should be able to do after this module

CapabilityEvidence you have it
Derive FiberExplain every structural field as forced by a requirement you hit
Explain render purityArgue it from discardability, not from functional programming
Diagnose a concurrency claimAsk the one question that separates render-bound from effect-bound
Explain useSyncExternalStoreReproduce tearing first, then say why an API was unavoidable
Govern a priority systemAnswer who may mark work low-priority, and how you stop drift

1. Why a Principal Engineer needs this

Concurrent rendering, transitions, Suspense and streaming SSR are all scheduling abstractions. You cannot evaluate them, or decide whether their complexity is worth it for your product, without having built the constraint they answer to.

The specific insight this module produces: recursion is the enemy of interruptibility. A recursive render walks the tree using the call stack as its state, and a call stack cannot be paused, inspected, resumed, or thrown away. Everything Fiber-shaped follows from wanting those four verbs.


2. Derivation, not transcription

Work through this in order. Each step should feel forced by the previous one.

  1. Your fw-02 renderer is recursive. Render a tree of 10,000 nodes. Measure the task length. Compare to the frame budget from bi-10. Observe the dropped frames directly.
  2. Try to yield. Attempt to pause halfway through the recursive render and resume later. Discover that you cannot — the state you would need to resume is spread across the call stack.
  3. Therefore: make the stack an explicit data structure. A node with child, sibling, return pointers, walked with a loop instead of recursion. Now "where am I" is a value you can hold.
  4. Now yielding is possible. Loop while work remains and time remains; otherwise schedule a continuation (bi-11: which primitive, and why not microtasks?).
  5. Now you need two phases. Interruptible work must not touch the DOM, because a partially applied update is user-visible. So: a render phase that builds, and a commit phase that applies atomically and cannot be interrupted.
  6. Now you need double buffering. Building the new tree while the current one is displayed means two trees and a pointer swap — an alternate.
  7. Now you can prioritise. Different updates get different deadlines; a high-priority update can abandon in-progress low-priority work. Which forces: work must be discardable, so the render phase must have no side effects.
  8. Batching falls out. Multiple setState calls in one task coalesce into one render, because rendering is scheduled rather than immediate.

At the end, write down what you built. Then read React's Fiber. Every structural element you derived should be recognisable, and anything in React you did not derive is a question worth answering.


3. Failure Lab

  1. Commit interruption. Deliberately yield in the middle of the commit phase. Produce a visibly half-updated UI. This is why commit is atomic.
  2. Side effects in render. Mutate something external during the render phase, then have that render discarded by a higher-priority update. Observe the corruption. This is why render must be pure — and it is a far better explanation than "React is functional."
  3. Yield too often. Measure total time as you shrink the yield interval. Find the point where scheduling overhead dominates.
  4. Yield too rarely. Measure input delay. Find the point where the user notices.
  5. Starvation. Continuously schedule high-priority updates; watch low-priority work never complete. Then design the escalation rule. (Compare Chromium's anti-starvation, bi-11.)
  6. Tearing. Read a mutable external value at two points in one interruptible render, mutating it in between. Observe inconsistent output. This is the problem useSyncExternalStore exists for — meet it before reading about it.

4. Trade-offs to argue

Interruptible vs synchronous rendering. Interruptibility costs an explicit work loop, two trees, purity constraints on render, and substantial conceptual complexity. It buys responsiveness under load only if the work is actually interruptible — effects and layout do not yield.

Time-slicing vs doing less work. Slicing a 200 ms render makes it responsive; not rendering 200 ms of work makes it fast. When is each right?

Priorities as a public API. Transitions expose scheduling to authors. Compare with scheduler.postTask (bi-11) — the same trade-off, one layer up.


4.5 Deep dive: why the call stack is the enemy

Step 2 of the derivation asks you to try to pause a recursive render. It is worth stating exactly why you cannot.

A recursive renderer stores its progress in the call stack: which child of which parent, at what depth, with which locals. The stack is owned by the language runtime. You cannot:

  • inspect it (where am I?),
  • suspend it and resume later,
  • discard it without unwinding,
  • hold two of them (the current tree and the one you are building).

All four are required for interruptible rendering. So the stack must become a data structure you own: nodes with child / sibling / return pointers, walked by a loop.

This is the general technique, not a React trick. Any algorithm that must be pausable — generators, coroutines, async state machines, incremental garbage collection (bi-04), the compositor's tile raster queue (bi-10) — converts implicit stack state into explicit heap state. Recognising "this needs to be interruptible, therefore the recursion must become a data structure" is a transferable design move.

The return pointer is worth noting: it is a parent pointer, which a recursive traversal never needs because the stack provides it. Its presence in the data structure is a direct artefact of having removed the stack.


4.6 Deep dive: two phases, and the invariant each protects

PhaseInterruptible?May touch DOM?Invariant
Renderyesnowork is discardable, so it must have no observable effect
Commitnoyesthe user never sees a partially applied update

These are two statements of one requirement. Work can only be thrown away if it has not done anything; and output can only be consistent if it is applied all at once.

Everything else follows:

  • Render must be pure — not for functional-programming reasons, but because impure render work cannot be discarded. Your failure lab proves this directly.
  • Effects run after commit — they are side effects by definition, so they cannot be in the render phase.
  • Layout effects run after DOM mutation but before paint — because they measure, and measuring before the mutation is useless while measuring after paint causes a visible flash.
  • Commit cannot yield — hence the spec's assertion that exactly one continuation performs DOM operations.

4.7 Deep dive: tearing, and the external-store problem

Interruptibility plus mutable external state is a hazard. Your reference implementation hit it, and so will yours.

render component A  →  reads external store (value = 1)
   yield
                       ...store mutates to 2...
   resume
render component B  →  reads external store (value = 2)
   commit  →  UI shows 1 and 2 simultaneously

Internal state is safe because the runtime controls when it changes. External state is not.

Three possible fixes, with their costs:

  1. Restart on change — any update makes in-flight work stale, discard and restart. Simple, correct, wastes work. (This is what the reference implementation does, and what the spec enforces.)
  2. Snapshot at render start — read a consistent snapshot; requires the store to support it.
  3. Subscribe with a consistency check — the useSyncExternalStore approach: get a snapshot, and detect if it changed mid-render.

React needed a first-class API for this because the ecosystem's stores are arbitrary third-party objects with no shared contract. That is the real lesson: concurrency is not a property you can add to a runtime alone — it constrains everything the runtime integrates with.

If you have ever wondered why useSyncExternalStore exists and looks awkward, this is the entire answer.


4.8 Deep dive: does time-slicing actually help?

Be honest about this, because it is a real design question and the marketing is not.

Time-slicing makes a long render interruptible. It does not make it shorter — in fact total wall-clock goes up slightly from scheduling overhead.

It helps when:

  • the work is genuinely long (tens of ms),
  • the work is in the render phase (effects and layout do not yield),
  • there is competing higher-priority work (input) to yield to,
  • and the user would otherwise perceive the delay.

It does not help when:

  • the expensive work is in an effect, a layout read, or a third-party script,
  • the total work is small and the overhead dominates,
  • or the real fix is doing less work.

"Concurrent React fixes our INP" is true only when input delay is caused by long render phases. It is false — and a costly distraction — when the long tasks are effects, data processing, or getBoundingClientRect loops. The single diagnostic question is: "is the long task a render, or something else?" A LoAF scripts[] breakdown (bi-11) answers it in one recording.


4.9 Deep dive: priorities as a product decision

startTransition exposes scheduling to authors, and that is a genuine interface-design question, not just an implementation detail.

The author is asserting: "this update is less urgent than input." The runtime is then free to abandon and restart it. That is only safe if the update is idempotent and side-effect-free, which the API cannot enforce — it can only document.

Compare scheduler.postTask (bi-11): same shape one layer down, same problem. Both hand a scheduling lever to someone who does not see the whole system.

The governance question for a team you lead: who is allowed to mark work low-priority, and how do you stop everything drifting to user-blocking? Priority systems degrade to uniformity unless someone owns the policy — which is exactly why Chromium's scheduler has anti-starvation logic rather than trusting priorities to be assigned honestly.


5. Verification

  • Explicit work-loop renderer replacing recursion
  • Yielding with a justified choice of primitive (bi-11)
  • Separate render and commit phases; commit atomic
  • Double buffering
  • At least two priority levels, with abandonment of in-progress work
  • Automatic batching
  • All six failure-lab bugs reproduced
  • Complexity-notebook entry: Fiber — the flagship §46 entry of this track
  • Measured: p75 input delay under load, recursive vs interruptible

Core terminology

TermDefinition
FiberA unit of work; a node in the explicit work tree with child/sibling/return pointers
return pointerThe parent link — a direct artefact of having removed the call stack
Work loopThe iterative traversal replacing recursion; the thing that can pause
Render phaseInterruptible, discardable, must not touch the DOM
Commit phaseAtomic, uninterruptible, applies all DOM mutations
Double bufferingcurrent and alternate trees; a pointer swap makes the new one live
Time slicingYielding periodically so the browser can render and process input
Lane / priorityThe representation of urgency; determines what may abandon what
AbandonmentDiscarding in-progress low-priority work when higher-priority work arrives
StarvationLow-priority work never completing; requires an explicit escalation rule
BatchingCoalescing multiple state updates into one render, because rendering is scheduled
TearingOne render observing two different values of a mutable external store
useSyncExternalStoreThe API that exists because external stores have no shared consistency contract
Passive effectRuns after paint; the ordinary useEffect
Layout effectRuns after DOM mutation, before paint; for measuring without a visible flash
OSR / continuationResuming interrupted work in a later task

Mental models

Interruptibility converts implicit stack state into explicit heap state. This is the transferable design move, not a React trick. Generators, coroutines, async state machines, incremental garbage collection (bi-04), and the compositor's tile raster queue (bi-10) all do it. Recognising "this needs to be pausable, therefore the recursion must become a data structure" is the reusable part.

The two phases are two statements of one requirement. Work can only be thrown away if it has not done anything; output can only be consistent if it is applied all at once.

PhaseInterruptible?May touch DOM?Invariant
Renderyesnowork is discardable, so it must have no observable effect
Commitnoyesthe user never sees a partially applied update

Everything else is a corollary. Render must be pure because impure work cannot be discarded — not for functional-programming reasons. Effects run after commit because they are side effects by definition. Layout effects run after mutation but before paint because they measure, and measuring before is useless while measuring after paint causes a visible flash.

Concurrency is not a property you can add to a runtime alone. It constrains everything the runtime integrates with. That is the real lesson of tearing: React needed a first-class API because the ecosystem's stores are arbitrary third-party objects with no shared consistency contract. If you have wondered why useSyncExternalStore exists and looks awkward, that is the entire answer.

Time-slicing trades throughput for responsiveness, and you must know who is paying. Identical to fe-01's measured result: two strategies at the same INP with a 3.4× wall-clock difference. Neither is wrong. The question is whether a human is watching incremental feedback or waiting for a final result.

Priority systems degrade to uniformity unless someone owns the policy. Everything drifts to user-blocking because every team believes their work is urgent. This is why Chromium's scheduler has anti-starvation logic rather than trusting priorities to be assigned honestly — and it is a governance question in your organisation, not a technical one.


Common misconceptions

  1. "Concurrent rendering makes the app faster." It makes it responsive. Total wall-clock goes up slightly. Those are different properties and conflating them produces disappointed teams.

  2. "Concurrent React will fix our INP." Only if the long task is a render phase. Effects, layout reads and third-party scripts do not yield. A LoAF scripts[] breakdown (bi-11) answers which situation you are in, in one recording.

  3. "Render must be pure because React is functional." It must be pure because discardable work cannot have side effects. Your own failure lab proves it, and the mechanical explanation is far more persuasive in a review than the ideological one.

  4. "Effects could be interrupted too, in principle." They are side effects; interrupting them leaves the world half-changed with no way to unwind. This is why exactly one continuation performs DOM operations.

  5. "Tearing is a React problem." It is a consequence of interruptibility plus mutable external state. Any interruptible renderer integrating arbitrary stores has it.

  6. "MessageChannel is an implementation detail." It is a forced choice: microtasks do not yield at all (fe-01 measured 0 frames rendered), and nested setTimeout clamps to 4 ms, capping throughput at ~250 slices/second regardless of slice size.

  7. "More yielding is better." Scheduling overhead dominates past a point; your failure lab finds it. Yield too rarely and input delay becomes perceptible. There is a budget, and 5 ms is a common landing spot for a reason.

  8. "Priorities solve contention." They redistribute it, and they starve the bottom unless you add an escalation rule. Design the escalation before you ship the priorities.


Interview talking points

  • "Everything Fiber-shaped comes from one sentence: recursion is the enemy of interruptibility. A recursive render keeps its progress on the call stack, and you can't inspect, suspend, discard or duplicate a call stack. So the stack becomes a data structure you own. The return pointer is the giveaway — a recursive traversal never needs a parent pointer, because the stack provides it."
  • "Render and commit are two statements of one requirement: work can only be thrown away if it hasn't done anything, and output is only consistent if it's applied at once. I'd rather argue render purity from discardability than from functional programming — it's mechanical, and it survives a review."
  • "'Concurrent React will fix our INP' is true only when the long task is a render phase. Effects and layout reads don't yield. One question tells you which situation you're in, and a LoAF breakdown answers it in one recording."
  • "Tearing taught me something general: concurrency isn't a property you can add to a runtime alone — it constrains everything the runtime integrates with. useSyncExternalStore exists because ecosystem stores are arbitrary objects with no shared consistency contract."
  • "React yields with MessageChannel and that's a forced choice, not a detail. Microtasks don't create a rendering opportunity at all — we measured zero frames over 400ms of microtask 'chunking' — and nested setTimeout clamps to 4ms, which caps you at about 250 slices a second."
  • "Priority systems drift to uniformity because every team thinks their work is urgent. So the question I ask is governance: who may mark work low-priority, and what's the escalation rule that stops the bottom starving? Chromium's scheduler has anti-starvation logic precisely because it doesn't trust priorities to be assigned honestly."

6. Principal Engineer Review

  1. Explain why interruptible rendering requires the render phase to be side-effect free, using your failure-lab result rather than an appeal to functional programming.

  2. Why can't effects be interrupted? What would break?

  3. React yields with MessageChannel rather than microtasks or setTimeout. Reconstruct the decision from bi-11, and say what scheduler.yield() changes.

  4. "Concurrent React fixes our INP." Under exactly which conditions is that true, and which false? What one question identifies which situation you are in?

  5. Tearing is a consequence of interruptibility plus external mutable state. Explain the mechanism, then evaluate the fix.

  6. Design a UI framework that is interruptible without a Fiber-like structure. What must you give up?

  7. Time-slicing makes a long render responsive but slightly slower overall. A senior engineer wants to revert it based on total wall-clock. Handle the disagreement — and say when they are right.

  8. Your app has a 300 ms render on a critical interaction. Rank: time-slice it, reduce the work, move it off-thread, or precompute. What decides?


Connections to other modules

  • fw-02 — hard prerequisite. Its recursive renderer is the thing you fail to pause in step 2, and stages 8–11 of the spec were deferred here on purpose.
  • fw-03 — the alternative answer to the same pressure: instead of making a large render interruptible, make it small enough not to need interrupting.
  • fw-05 — component-level reactive scoping as a third strategy, and the scheduler that dedupes and flushes it.
  • fw-11 — read React's Fiber fields, lanes, work loop and commit sub-phases against what you derived. Anything you did not derive is a question worth answering.
  • bi-11 — hard prerequisite; the scheduling primitives, queue priorities and anti-starvation logic this module's design answers to.
  • bi-10 — the frame budget that makes "yield" a measured constraint rather than a word.
  • fe-01 — the same trade measured from outside: microtask chunking rendering 0 frames, and identical INP at 3.4× the wall clock.
  • fe-12 (concurrent rendering) — the application-level consequences of everything derived here.