React Fiber Architecture: How Incremental Rendering Enables Concurrent Features
React Fiber restructures reconciliation into an interruptible two-phase work loop with priority lanes, enabling concurrent features like transitions and Suspense while preserving backward compatibility.
13 Jul 2026, 04:43 UTC

The Problem: Blocking Reconciliation at Scale
React's original stack reconciler executed the entire component tree recursively in a single synchronous pass. On large trees, this blocked the main thread for hundreds of milliseconds, causing dropped frames, unresponsive inputs, and janky animations. The core requirement was to make rendering interruptible—allowing high-priority updates (typing, clicks) to preempt lower-priority work (background data fetching, offscreen transitions) without losing already-completed work.
Takeaway: Fiber achieves this by restructuring reconciliation into a two-phase work loop (render/commit) with a linked-list tree of fiber nodes, each representing a pausable unit of work scheduled by priority lanes. This design enables time-slicing, Suspense, transitions, and error boundaries without changing the public component model.
Requirements Driving the Design
- Interruptibility: Work must be pausable, resumable, and abortable based on dynamic priority.
- Consistent UI: Mutations must apply atomically so users never see partial renders.
- Priority expression: Urgent vs. background updates need explicit semantics, not ad-hoc heuristics.
- Backward compatibility: Existing class components, lifecycle methods, and refs must continue working.
- Extensibility: Custom renderers (React Native, Three.js, terminal UIs) should reuse the core scheduler.
Smallest Suitable Design: Two-Phase Work Loop with Double Buffering
Render Phase (Async, Interruptible)
The renderer walks the fiber tree—a linked list where each node points to child, sibling, and return fibers. For each fiber, beginWork performs reconciliation (diffing props, creating child fibers) and completeWork finalizes host-side preparation (e.g., DOM node creation). This phase builds a work-in-progress (WIP) tree alternate to the current committed tree. No host mutations occur here.
The scheduler (in the scheduler package) drives the loop using requestIdleCallback (polyfilled via postMessage) to yield between fiber units. Each unit checks shouldYield(); if true, control returns to the browser. Priority is expressed through lanes (bitmasks): SyncLane (urgent), InputContinuousLane (drag/scroll), DefaultLane (normal), IdleLane (background). Higher-priority updates can interrupt and clone only the affected subtree, reusing completed WIP fibers from lower-priority work.
Commit Phase (Synchronous, Atomic)
Once the WIP tree is complete, React swaps the current pointer (double buffering) and runs three sub-phases synchronously:
- beforeMutation:
getSnapshotBeforeUpdate, suspending passive effects. - mutation: DOM insertions, deletions, updates; ref detachment/attachment.
- layout:
componentDidMount/Update,useLayoutEffectcallbacks (fire before paint).
After layout, useEffect callbacks are scheduled as passive effects to run asynchronously after paint.
Trust and Data Boundaries
Internal vs. Public API
Fiber internals (ReactInternalTypes, workLoopSync, workLoopAsync, lane bitmasks) are not public API. They change across minor versions. The trust boundary is the documented hook surface: useTransition, useDeferredValue, useEffect, useLayoutEffect, startTransition, Suspense, error boundary lifecycles (componentDidCatch, getDerivedStateFromError). Custom renderers integrate via the HostConfig interface, not by forking the work loop.
Error Boundaries: Render-Phase Only
Error boundaries catch errors thrown during render phase of descendant components (including beginWork/completeWork). They do not catch errors in event handlers, useEffect callbacks, async promises, or the boundary's own render. When an error occurs, React unwinds the WIP tree to the nearest boundary, schedules a fallback render at SyncLane, and calls onUncaughtError root option (React 18+).
Operational Checks: Verifying Behavior in Development
1. Observe Lane Priorities and Commit Phases
Create a minimal React 18 app with createRoot, wrap a lazy component in <Suspense>, and open React DevTools Profiler. Record a profile while triggering a transition (startTransition(() => setTab('heavy'))). In the flamegraph, inspect the Lane column: urgent updates show Sync, transitions show Default or Transition. Commit phases appear as distinct bars labeled Commit with sub-phase timings.
2. Instrument a Custom Renderer Fork
Fork react-reconciler, add logging in beginWork, completeWork, commitWork. Verify that for each update, the WIP tree alternates with the current tree (fiber.alternate points to the other). Confirm effect tags (Placement, Update, Deletion, Callback, Passive) accumulate on fibers and execute in deterministic order during commit.
3. Test Error Boundary Fallback Rendering
// ErrorBoundary.jsx
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(err, info) { console.error('Caught:', err, info); }
render() { return this.state.hasError ? <Fallback /> : this.props.children; }
}
// Test: throw in render
function Throwing() { throw new Error('render boom'); }
// Wrap and assert fallback appears
// Use root.onUncaughtError to capture logged error in React 18
Run with Jest + React Testing Library; assert Fallback mounts and error logs via onUncaughtError.
4. Measure Main-Thread Yielding with Transitions
In Chrome Performance tab, record a large tree update (e.g., 5000 list items) with and without startTransition. Without transition: single long task (>200ms). With transition: multiple short tasks (~5-16ms each) separated by idle periods. Total time may increase slightly, but interaction latency (INP) improves.
5. Hydration Mismatch Detection
// Server
ReactDOMServer.renderToString(<App />);
// Client
ReactDOM.hydrateRoot(rootEl, <App />);
// Introduce mismatch: date formatting differs server vs client
In development, console warns Hydration failed and remounts the mismatched subtree. In production, remount is silent. Ensure deterministic rendering (avoid Date.now(), Math.random(), unkeyed lists).
Failure Modes and Mitigations
| Failure Mode | Symptom | Mitigation |
|---|---|---|
Heavy work in useLayoutEffect | Blocks paint, causes visual jank | Move non-visual side effects to useEffect; keep useLayoutEffect for DOM measurements only |
| Uncaught error in event handler | Crashes root, no fallback | Wrap handlers in try/catch or use react-error-boundary HOC for async boundaries |
| Hydration mismatch in production | Silent remount, lost state, flicker | Enforce deterministic SSR; use suppressHydrationWarning sparingly on known-dynamic nodes |
| Custom renderer HostConfig bugs | Memory leaks, corrupted trees, crashes | Implement all required methods (createInstance, appendChild, prepareUpdate, commitUpdate, removeChild); test with stress updates |
| Priority inversion (starvation) | Low-priority work never completes | React's lane system includes expiration; avoid manual unstable_* lane manipulation |
Conditions That Would Change the Design
- Native scheduler API standardization: If
scheduler.yield()ornavigator.schedulingbecomes widely supported, the polyfill layer could be removed, reducing bundle size and improving yield precision. - WebAssembly host targets: For renderers targeting Wasm (e.g., React Native Skia, WebGL), the HostConfig boundary may need async commit primitives to bridge sync/async boundaries without blocking the UI thread.
- Fine-grained reactivity adoption: If signals/observables become a first-class primitive, the fiber diffing algorithm could shift from tree-walk to dependency-graph invalidation, changing the unit of work from fiber to signal node.
- Off-main-thread rendering: With
OffscreenCanvasor Worker DOM proposals, the commit phase could move off the main thread, requiring a serialized mutation protocol instead of direct DOM calls.
Practical Verification Checklist
- Profile a transition-heavy interaction; confirm multiple short tasks in Performance tab.
- Trigger an error in a deep component; verify error boundary fallback renders and
onUncaughtErrorfires. - Compare SSR HTML with client hydration; introduce a mismatch and observe remount scope.
- Stress-test a custom renderer with rapid updates; check for memory growth (Chrome DevTools Memory tab).
- Audit
useLayoutEffectusage; ensure none perform I/O, heavy computation, or state updates that trigger re-renders.
Limitations
- Fiber's priority system is cooperative—long-running synchronous code (e.g., massive
reducein render) still blocks. UseuseMemoor move to Web Workers. - Error boundaries cannot recover from errors in the boundary itself or in root-level render.
- Hydration mismatches on unkeyed lists cause O(n) remounts; always key dynamic lists.
- Custom renderers must reimplement host context (namespaces, style delegation, event systems); React DOM's HostConfig is ~1500 lines.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.