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."
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.
- Your
fw-02renderer is recursive. Render a tree of 10,000 nodes. Measure the task length. Compare to the frame budget frombi-10. Observe the dropped frames directly. - 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.
- Therefore: make the stack an explicit data structure. A node with
child,sibling,returnpointers, walked with a loop instead of recursion. Now "where am I" is a value you can hold. - Now yielding is possible. Loop while work remains and time remains; otherwise schedule
a continuation (
bi-11: which primitive, and why not microtasks?). - 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.
- Now you need double buffering. Building the new tree while the current one is displayed means two trees and a pointer swap — an alternate.
- 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.
- Batching falls out. Multiple
setStatecalls 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
- Commit interruption. Deliberately yield in the middle of the commit phase. Produce a visibly half-updated UI. This is why commit is atomic.
- 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."
- Yield too often. Measure total time as you shrink the yield interval. Find the point where scheduling overhead dominates.
- Yield too rarely. Measure input delay. Find the point where the user notices.
- Starvation. Continuously schedule high-priority updates; watch low-priority work never
complete. Then design the escalation rule. (Compare Chromium's anti-starvation,
bi-11.) - Tearing. Read a mutable external value at two points in one interruptible render, mutating
it in between. Observe inconsistent output. This is the problem
useSyncExternalStoreexists 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
| Phase | Interruptible? | May touch DOM? | Invariant |
|---|---|---|---|
| Render | yes | no | work is discardable, so it must have no observable effect |
| Commit | no | yes | the 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:
- 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.)
- Snapshot at render start — read a consistent snapshot; requires the store to support it.
- Subscribe with a consistency check — the
useSyncExternalStoreapproach: 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
getBoundingClientRectloops. The single diagnostic question is: "is the long task a render, or something else?" A LoAFscripts[]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
6. Principal Engineer Review
-
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.
-
Why can't effects be interrupted? What would break?
-
React yields with
MessageChannelrather than microtasks orsetTimeout. Reconstruct the decision frombi-11, and say whatscheduler.yield()changes. -
"Concurrent React fixes our INP." Under exactly which conditions is that true, and which false? What one question identifies which situation you are in?
-
Tearing is a consequence of interruptibility plus external mutable state. Explain the mechanism, then evaluate the fix.
-
Design a UI framework that is interruptible without a Fiber-like structure. What must you give up?
-
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.
-
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?