React fiber and virtual DOM update process
React Fiber is React 16's reimplementation of the reconciliation engine as a linked-list data structure that enables incremental, interruptible, and priority-based rendering of the virtual DOM.
Theory
TL;DR
- Fiber is like an assembly line with pause buttons: old React processed the entire component tree in one synchronous pass (blocking the browser for 100ms+ on large trees); Fiber pauses low-priority work to keep user interactions responsive.
- Main difference: stack-based reconciler (pre-16) vs linked-list fiber nodes. The linked list can be paused mid-tree and resumed from the exact same node.
- Each fiber node stores three pointers:
child(first child),sibling(next sibling),return(parent). createRootin React 18 enables full concurrent features;ReactDOM.renderstays on the legacy synchronous path.- Rule of thumb: large lists (500+ items) or animations running alongside data fetches - use
startTransitionoruseDeferredValue.
Quick example
import { useState, useTransition } from 'react';
function App() {
const [input, setInput] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
setInput(e.target.value); // high priority: runs immediately
startTransition(() => {
filterLargeList(e.target.value); // low priority: Fiber schedules in background
});
};
return <input value={input} onChange={handleChange} placeholder="Type without lag" />;
}startTransition tells Fiber this update can wait. The input stays responsive because Fiber yields the heavy work to the browser between 16ms frames, resuming only when the main thread is free.
Stack reconciler vs Fiber
Before React 16, the reconciler walked the component tree using the call stack: one big synchronous operation. A tree with 1,000 nodes at 0.1ms each locked the browser for 100ms. No pausing, no yielding. The user clicked a button and nothing happened until React finished.
Fiber replaces the call stack with a linked list. React walks it via nextUnitOfWork, processes 5-10ms of work per chunk, then checks if the browser needs to paint or handle an event. If yes, it yields via shouldYield() and returns to nextUnitOfWork exactly where it stopped. The work-in-progress tree is the current.alternate clone, so the committed UI stays intact while React builds the next version.
That is the whole architecture change.
Render phase: building the work-in-progress tree
Every state change from useState or setState enters an update queue attached to the fiber. React reads that queue and starts building a work-in-progress (WIP) tree by cloning the current fiber tree with the new state applied.
During this phase, React calls your render functions and runs reconcileChildFibers to diff props and state against the last committed fiber. If element types match, it patches the existing fiber node. If a type changes (say, div to span), React tears down that branch and builds a new one from scratch.
Keys matter here. For lists, key props let React match old and new nodes by identity rather than position. Without keys, swapping two list items registers as two separate changes instead of zero.
The render phase is pure and pausable. No DOM mutations happen here.
Commit phase: atomic and non-interruptible
Once the WIP tree is complete, React enters the commit phase. This part cannot be paused.
Three sub-phases run in sequence:
getSnapshotBeforeUpdatereads the DOM before any mutations.commitMutationEffectsadds, removes, and updates real DOM nodes.commitLayoutEffectsfirescomponentDidMount,componentDidUpdate, anduseLayoutEffectsynchronously.
After the browser paints, useEffect runs asynchronously.
The commit phase is always synchronous. In practice, this is the thing that catches most developers off guard: rendering 10,000 real DOM nodes in one commit still blocks the browser regardless of concurrent mode. Fiber splits the work of building the WIP tree, not the work of writing to the DOM. That is why virtualization with react-window still matters for long lists.
How Fiber schedules work
Fiber does not use requestIdleCallback directly because browser support is inconsistent and its 50ms minimum delay is too coarse. Instead, React's scheduler package polyfills it with MessageChannel postMessage loops, slicing work into ~5ms chunks. The shouldYield() function checks a deadline; when time is up, the current unit of work pauses and control returns to the browser.
Priority is encoded as 32-bit lane bitmasks. SyncLane (bit 0) handles user input. Higher bits correspond to lower-priority work: transitions, deferred values, background tasks. When startTransition wraps a state update, React ORs TransitionLane into the fiber's lanes field and propagates it up the tree via markUpdateLaneFromFiberToRoot. Higher-priority lanes (lower bit number) preempt lower-priority ones.
When to use concurrent features
Large lists or tables with 500+ items: wrap the heavy state update in startTransition to prevent input lag.
Animations running alongside expensive re-renders: useDeferredValue keeps the animation lane separate from the slow data lane.
Simple apps with small component trees: default synchronous mode is fine. Concurrent mode adds scheduling overhead without benefit below roughly 100 nodes.
Legacy codebases on ReactDOM.render: these run the stack-based path. Migrating to createRoot enables concurrent features, but some lifecycle patterns and third-party libraries behave differently. Test thoroughly before switching.
Comparison table
| Feature | Stack reconciler (pre-16) | Fiber (16+) |
|---|---|---|
| Internal structure | Call stack | Linked list (child / sibling / return) |
| Interruptible | No - one atomic pass | Yes - pause and resume per node |
| Priority levels | None | 32-bit lane bitmask (5+ levels) |
| Scheduling | setTimeout hacks | scheduler package with MessageChannel |
| Concurrent mode | Not possible | Available via createRoot |
| When to use | Tiny apps, no animations | Production apps with complex trees |
Common mistakes
Wrapping everything in startTransition. Transitions defer re-renders, but the state update that drives the input value still needs to run synchronously. Wrapping that update in a transition makes the input itself lag.
// Wrong: input lags because the value update is deferred
startTransition(() => setInputValue(e.target.value));
// Right: defer only the expensive secondary update
setInputValue(e.target.value); // immediate
startTransition(() => setFiltered(computeFilter(e.target.value)));Assuming concurrent mode removes all jank. The commit phase is always synchronous. If 20,000 DOM nodes land in one commit, the browser blocks. Fiber cannot split that.
// Still jank: 20,000 real DOM nodes committed at once
{items.map(item => <div key={item.id}>{item.text}</div>)}
// Fix: virtualize
<FixedSizeList height={500} itemCount={20000} itemSize={35}>
{Row}
</FixedSizeList>Using flushSync for every "immediate" update. flushSync forces a synchronous render and bypasses Fiber's scheduling entirely. Used frequently, it causes jank cascades. Reserve it for cases where you must read layout immediately after a state update (focus management, scroll anchoring).
Ignoring StrictMode's double-invoke with async transitions. In development, StrictMode runs effects twice to surface side effects. A fetch inside useEffect wrapped in startTransition fires twice. Without an AbortController or a cancelled flag, the first response can overwrite the second. This is expected behavior from StrictMode, not a Fiber bug.
Real-world usage
- React 18 apps:
createRoot+startTransitionfor responsive typing in large task lists, as seen in tools like Linear. - Next.js 13+ app router: automatic concurrent rendering; server components stream using Fiber lanes to prioritize above-the-fold content.
- Redux Toolkit:
useSelectorcombined withuseDeferredValuefor admin dashboards with heavy store updates. - TanStack Query:
useSuspenseQueryintegrates with Fiber's bailout mechanism to abort stale queries when a higher-priority update interrupts.
Follow-up questions
Q: Draw Fiber's linked-list structure. What are the three pointers on each node?
A: Each fiber has child (first child component), sibling (next component at the same level), and return (parent). Traversal goes: try workInProgress.child; if null, try workInProgress.sibling; if null, walk workInProgress.return until a sibling is found. The WIP tree is current.alternate.
Q: How does Fiber schedule work without requestIdleCallback?
A: React's scheduler package uses MessageChannel postMessage to get a callback after the browser paints. Inside each callback, shouldYield() checks a ~5ms deadline and returns true when time runs out. This gives finer control than requestIdleCallback, which has a 50ms minimum idle period.
Q: What is the difference between the render phase and the commit phase?
A: Render phase is pure and pausable - it builds the WIP tree, diffs fiber nodes, and collects an effect list. No DOM changes. Commit phase is synchronous and atomic - it applies DOM mutations, then runs lifecycle methods and effects in a fixed order.
Q: Explain lane bitmasks. Give an example with two priorities.
A: Lanes are a 32-bit integer. Bit 0 (value 1) is SyncLane for user input. Bit 4 (value 16) is TransitionLane for startTransition updates. React ORs the new lane into the fiber's lanes field and runs lower bit numbers first. So user input always preempts a transition in flight.
Q (senior): Why does useDeferredValue not cause an infinite re-render loop even in StrictMode?
A: useDeferredValue schedules a low-priority re-render with the deferred value. On that re-render, the deferred value matches the current value, so React bails out because no state changed. StrictMode double-invokes the render function, but both invocations share the same current fiber and bail out identically. No new update is scheduled.
Q (senior): Why does SSR not use Fiber's concurrent scheduling?
A: SSR streams HTML synchronously to the client. There is no browser scheduler to yield to - no paint frames, no user events to interrupt for. React uses a simpler synchronous render path on the server. ReactDOMServer.renderToString processes the entire tree in one pass.
Examples
Basic: seeing Fiber yield in action
import { useState, useTransition } from 'react';
function SlowList({ count }) {
return (
<>
{Array.from({ length: count }, (_, i) => (
<div key={i} style={{ padding: '4px', borderBottom: '1px solid #eee' }}>
Item {i + 1}
</div>
))}
</>
);
}
function App() {
const [count, setCount] = useState(100);
const [isPending, startTransition] = useTransition();
return (
<div>
<button
onClick={() => startTransition(() => setCount(c => c + 500))}
>
Add 500 items {isPending && '(rendering...)'}
</button>
{/* Fiber renders SlowList in background, yields to button clicks */}
<SlowList count={count} />
</div>
);
}isPending turns true while Fiber is still building the WIP tree for the new count. The button stays clickable and responsive throughout the update. Without startTransition, clicking Add 500 items would block the UI until the commit finishes.
Intermediate: useDeferredValue for a filtered list
import { useState, useDeferredValue, memo } from 'react';
const ItemList = memo(({ filter, items }) => {
const filtered = items.filter(item =>
item.label.toLowerCase().includes(filter.toLowerCase())
);
return (
<ul>
{filtered.map(item => <li key={item.id}>{item.label}</li>)}
</ul>
);
});
function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // low-priority copy
const items = useMemo(() => generateItems(1000), []);
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Filter items..."
/>
{/* Re-renders with deferredQuery - always one step behind query */}
<ItemList filter={deferredQuery} items={items} />
</>
);
}deferredQuery lags one or more renders behind query. Fiber schedules the ItemList re-render at low priority. The input updates on every keystroke without waiting for the list to filter. memo prevents ItemList from re-rendering when only query (not deferredQuery) changes.
Advanced: concurrent fetch in StrictMode with cleanup
import { useState, useEffect, useTransition } from 'react';
function DataFetcher() {
const [data, setData] = useState(null);
const [isPending, startTransition] = useTransition();
useEffect(() => {
let cancelled = false;
// StrictMode double-invokes this effect in development:
// mount -> cleanup (cancelled = true) -> mount again
// Without the flag, the first response can overwrite the second
startTransition(() => {
fetch('/api/heavy-data')
.then(res => res.json())
.then(json => {
if (!cancelled) setData(json); // bail if effect was cleaned up
});
});
return () => { cancelled = true; };
}, []);
if (isPending) return <p>Loading...</p>;
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}Fiber's shouldYield can also abort the low-priority transition mid-execution if a higher-priority update arrives between the fetch start and its resolution. The cancelled flag handles both StrictMode double-invoke and that interruption case. Without it, you get either a stale state write or a React warning about updating an unmounted component.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.