What is React Fiber? How React Works Under the Hood

Estimated read: ~18 minutes. I'll assume you're comfortable with React basics — components, state, the "virtual" DOM, and the idea of "reconciliation" — and ready to peek under the hood. We'll build everything else up together. Most of us learn React as a friendly black box: you change some state, the UI updates, everyone goes home. That model holds up fine until the day your app feels sluggish, or an interview asks: What actually happens between setCount(1) and the screen changing? And you real
Estimated read: ~18 minutes.
I'll assume you're comfortable with React basics — components, state, the "virtual" DOM, and the idea of "reconciliation" — and ready to peek under the hood. We'll build everything else up together.
Most of us learn React as a friendly black box: you change some state, the UI updates, everyone goes home.
That model holds up fine until the day your app feels sluggish, or an interview asks:
What actually happens between
setCount(1)and the screen changing?
And you realize you've been trusting magic.
So let's sit down and open the box.
This guide comes in two halves:
- Part I builds the mental model: what Fiber is, why React needed it, and the handful of ideas — units of work, double buffering, two phases, cooperative scheduling, and lanes — that make it tick.
- Part II spends that model on a concrete walkthrough: we follow a single button click through a tiny counter, step by step, all the way to the painted pixel.
By the end, none of the jargon will be jargon anymore.
Part I — The mental model
1. What exactly is React Fiber?
React Fiber is the complete rewrite of React's core reconciliation algorithm, shipped in React 16.
It is not an API you call. You still write components exactly the same way. Fiber is the engine underneath that decides how React figures out what changed and when it applies those changes to the DOM.
Think of it as React's internal operating system for scheduling and rendering work.
2. Why React needed Fiber: the stack reconciler problem
Before Fiber, React used a recursive algorithm that traversed the component tree synchronously, top to bottom, in one shot.
Once an update started, it ran to completion. This was the stack reconciler, so named because it leaned on the JavaScript call stack to track its position in the tree.
The trouble is that JavaScript runs on the main thread — the same thread that handles user input, animations, and layout.
If reconciliation takes too long, the browser can't paint frames on time. The result is jank: stuttering animations, missed keystrokes, an interface that ignores you for a beat.
And you can't pause a call stack.
Being "halfway through" the tree means being deep inside nested function calls. There's no way to save that, let the browser do something urgent, and come back.
React needed a way to:
- break rendering into small chunks,
- pause work and let the browser handle urgent tasks,
- resume later — or throw incomplete work away if something more important arrived,
- and assign different priorities to different updates.
Fiber was built to do exactly that.
3. The big idea: incremental rendering and cooperative scheduling
Fiber's headline feature is incremental rendering: splitting rendering into units of work and spreading them across multiple frames.
Instead of one giant recursive call that can't be stopped, Fiber builds a linked-list tree of work units and drives them with a work loop that can pause after each unit, check whether something more urgent is waiting, and either yield or keep going.
This is cooperative scheduling — React voluntarily hands control back to the browser so the page stays alive.
Nobody interrupts React; React chooses to step aside.
4. The Fiber node: a unit of work
In the old reconciler, the virtual DOM was a tree of React elements.
In Fiber, the internal tree is made of fiber nodes. Each fiber corresponds to a component instance, a DOM node, or another piece of work — and it's just a plain JavaScript object.
const fiber = {
type: Counter,
key: "counter-1",
stateNode: domNode,
child: firstChildFiber,
sibling: nextSiblingFiber,
return: parentFiber,
pendingProps: {
count: 1,
},
memoizedProps: {
count: 0,
},
memoizedState: firstHook,
alternate: previousFiber,
flags: Update | Placement,
lanes: SyncLane,
};
Enter fullscreen mode Exit fullscreen mode
The fields that matter:
-
type— the component function/class, or an HTML tag string like'div' -
key— identity for list reconciliation -
child/sibling/return— links to the first child, the next sibling, and the parent -
stateNode— the real DOM node for host components, or the class instance for class components -
pendingProps/memoizedProps— incoming props vs. last-rendered props -
memoizedState— where a function component's hooks live. This is the one people get wrong: it's notstateNode -
alternate— link to this fiber's twin in the other tree: current ↔ work-in-progress -
flags— formerlyeffectTag; what side effect to apply:Placement,Update,Deletion, and so on -
lanes— formerlypendingWorkPriority; how urgent this work is
The child / sibling / return links are the crucial bit.
Because the tree's structure is stored as data on these objects rather than as a position on the call stack, React can traverse it iteratively instead of recursively.
That means it can stop at any node by breaking out of a loop, and resume later from exactly that node.
5. Double buffering: two trees at once
At any moment Fiber keeps two trees:
- the current tree — what's rendered on screen right now
- the work-in-progress tree — the new version being built during reconciliation
They mirror each other through the alternate pointer.
When an update begins, React builds work-in-progress fibers from the current ones, reusing what it can.
If the update finishes successfully, the work-in-progress tree becomes the new current tree — by swapping a single pointer — and the old tree is recycled.
This buys two things:
- React can work on the next version without mutating what you're looking at.
- React can reuse fiber objects across renders instead of reallocating everything.
6. The two-phase model: render, then commit
Fiber splits an update into two distinct phases — and understanding the split is what separates "I use React" from "I understand React."
Phase 1 — render phase
The render phase is interruptible.
React builds the work-in-progress tree and figures out what changed.
It walks the fibers via child / sibling / return, calls your component functions to get new elements, reconciles those against the previous output, and tags fibers with flags like Placement, Update, and Deletion.
This phase is asynchronous and interruptible: React can process a fiber, yield to the browser, and resume later.
Or, if a higher-priority update lands, React can throw the whole work-in-progress tree away and start over.
Critically, nothing visible happens yet. That is why this phase must be pure.
That's the real reason you must not put side effects in a component body or in render():
React may run it, abandon it, and run it again.
Phase 2 — commit phase
The commit phase is synchronous and uninterruptible.
Once the work-in-progress tree is complete and every change is collected, React applies it.
It performs the DOM mutations for each flagged fiber — insert, update, delete — runs componentDidMount, componentDidUpdate, useLayoutEffect, and ref callbacks, and swaps the work-in-progress tree in as the new current tree.
useEffect is scheduled separately, after the paint.
This phase runs in one synchronous burst and cannot be interrupted. The user must never glimpse a half-applied UI.
It's fast because all the thinking already happened during render.
7. The work loop and the Scheduler
The work loop, simplified, looks like this:
function workLoop() {
while (nextUnitOfWork !== null && !shouldYield()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (nextUnitOfWork !== null) {
scheduleCallback(workLoop); // ran out of time → continue later
} else {
commitRoot(); // tree finished → commit
}
}
Enter fullscreen mode Exit fullscreen mode
performUnitOfWork does two things per fiber:
- It calls
beginWork, which reconciles the fiber's children and returns the first child as the next unit of work. - When a fiber has no more children, it calls
completeWorkwhile walking back up through siblings and parents.
This is where a host fiber's DOM node is created or updated and its effects are gathered.
The loop remembers its place purely through nextUnitOfWork.
8. Priorities and the lanes model
Not every update deserves the same urgency.
Typing must feel instant. A data-fetch response can wait. Offscreen content can render lazily.
React encodes this with lanes.
A lane is a single bit in a 31-bit integer, representing a priority band.
Each update is assigned one or more lanes. The Scheduler processes the highest-priority lane first.
If a higher-priority lane becomes ready while React is mid-render on a lower one, React can interrupt the low-priority work and restart with the urgent update, discarding the stale draft.
This is the machinery behind time slicing and concurrent rendering: React can prepare different versions of the tree at different priorities and throw away whatever is no longer relevant.
9. What Fiber unlocks: concurrent features
Almost every modern React feature is a direct consequence of this idea:
Rendering can be paused, prioritized, and resumed.
That unlocks:
- Suspense — pause rendering of a subtree while it waits for data, without blocking the rest of the tree
-
Transitions —
startTransitionmarks an update as non-urgent so it never interrupts typing or animation -
useDeferredValue— lets a value lag slightly to keep the UI responsive - Selective hydration — hydrates parts of server-rendered HTML independently, without blocking interactivity
None of these would be possible on top of the old synchronous stack reconciler.
Part II — Watching it happen
Concepts are slippery until you trace them on something real.
So here's the example:
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
On first render the screen shows Count: 0 and a button.
The user clicks +1.
Let's follow that single click all the way to the screen.
I'll use useState, but class setState follows almost the identical path.
Step 0 — what exists before the click
React has already built a fiber tree from the initial render:
RootFiber (HostRoot)
└─ child → Counter (function component, state: 0)
└─ child → <div>
├─ child → <p> → "Count: 0"
└─ sibling → <button> → "+1"
Enter fullscreen mode Exit fullscreen mode
Step 1 — the trigger: setCount(count + 1)
setCount is a dispatcher React handed you, not a plain assignment.
Calling setCount(1) does four things in quick succession.
- Creates an Update object — a small record of the intent:
{
action: 1, // the new value, or an updater function
lane: 0b01000, // the priority lane, simplified
next: null // pointer for the circular list of updates
}
Enter fullscreen mode Exit fullscreen mode
Assigns it a lane. A click gets a high-priority lane; a startTransition update gets a low one; an ordinary update gets "default."
Enqueues it on the hook’s updateQueue on the Counter fiber. Multiple pending updates chain into a circular linked list.
Schedules work on the root. Starting at Counter, React walks up via return to the HostRoot and flips the matching bit in the root's pendingLanes, then notifies the Scheduler.
The part beginners never expect:
The state hasn't changed yet.
count is still 0 everywhere.
We've only recorded an intent and rung a bell.
Step 2 — the Scheduler picks up the work
When the browser yields a moment, the Scheduler posts a task via MessageChannel, so it runs after the current event but doesn't block it.
The Scheduler finds the root with pending lanes, picks the highest-priority lane, and kicks off the render phase with a deadline.
Step 3 — the render phase: figuring out what changed
The work loop starts at the root and processes one fiber at a time via beginWork, walking down through child.
beginWork(Root) → bail out.
The root has no pending state, so React clones it into the work-in-progress tree and returns its child, Counter.
beginWork(Counter) → real work.
The Counter fiber has pending lanes, so React rolls up its sleeves:
- First it processes the update queue: it reads the current
memoizedState(0), applies each queued update — our{ action: 1 }→1— and writes the new value onto the work-in-progress fiber. The on-screen fiber still says0. - Then it calls the component:
Counter()runs withcount = 1and returns new elements:<p>Count: 1</p>and the same button. - Then reconciliation happens: React diffs those new elements against the previous children. The
<div>matches by type and key, so it is cloned and flaggedUpdate.
beginWork(<p>) → same type, but its text changed from "Count: 0" to "Count: 1", so it is cloned and flagged Update.
React then moves to the sibling.
beginWork(<button>) → bail out.
Its props and onClick are unchanged, so React clones it, copies the props, adds no flags, and skips its entire subtree.
That word bailout earns its keep.
React looks at a fiber, decides nothing changed, and refuses to re-render it or visit its children.
It's why a state change buried deep in a huge app stays cheap: React only walks the path that actually changed.
Step 4 — completeWork: build the nodes, gather the changes
When a fiber runs out of children, React calls completeWork and walks back up.
For host components, completeWork creates or updates the actual DOM node and stores it in stateNode, but doesn't insert it into the live page yet.
It also collects the flagged fibers so the commit phase has a tidy list of operations.
React 16/17 threaded flagged fibers into a singly-linked "effect list" via
nextEffect. React 18 removed that list and bubbles asubtreeFlagssummary up each parent instead, so it can skip whole subtrees with no work. Same intuition — "remember what needs DOM work" — but in modern source you'll findsubtreeFlags, notnextEffect.
Step 5 — pausing for the browser when needed
After each unit of work, !shouldYield() checks whether the ~5ms slice is spent.
If the browser needs to paint or handle input, the loop stops, saves nextUnitOfWork, and schedules a continuation.
And if a higher-priority update arrives mid-render, React can discard the work-in-progress tree and restart with the urgent work first.
So a click never waits behind a slow background transition.
Our tiny counter finishes in one slice, but the capability to pause is the whole point.
Step 6 — the commit phase: making it real
The work-in-progress tree is complete and React knows every change.
Commit is synchronous and uninterruptible, in three sub-phases.
6a. Before mutation
React reads the old DOM before changing it.
getSnapshotBeforeUpdate runs here for class components. Nothing special happens for our counter.
6b. Mutation
React applies DOM operations by flag.
Here, it patches the <p> text node to "Count: 1".
Then comes the elegant finish: the tree swap.
root.current = workInProgress;
Enter fullscreen mode Exit fullscreen mode
The freshly built tree instantly becomes the live tree.
The old one becomes recyclable scratch space.
One pointer, no copying.
6c. Layout effects
The DOM is updated but unpainted.
React synchronously runs useLayoutEffect and componentDidUpdate.
Because this is before paint, you can measure layout and correct it synchronously.
But it blocks the paint, so use it sparingly.
Step 7 — paint, then passive effects
The browser paints the new frame. The user finally sees Count: 1.
After paint, React runs passive effects — useEffect callbacks — asynchronously, so they never block the pixels.
Cleanup from the previous render runs first.
If an effect calls setState, the whole cycle restarts, properly scheduled.
That ordering — layout effects before paint, passive effects after — is the source of a hundred subtle bugs and their fixes.
Now you know why it exists.
User clicks → setCount(1)
│
├─ Update object enqueued, root's pendingLanes marked, Scheduler notified
▼
[Scheduler] picks the highest-priority lane → starts the work loop
▼
[Render phase — interruptible]
├─ beginWork(Root) → bail out
├─ beginWork(Counter) → process state (0→1), run Counter(), reconcile
│ ├─ <p> → flag: Update (text changed)
│ └─ <button> → bail out (unchanged)
└─ completeWork → build DOM nodes, gather flagged fibers
▼
[Commit phase — synchronous, cannot pause]
├─ mutation: patch <p> text, swap trees (root.current = WIP)
└─ layout effects: useLayoutEffect / componentDidUpdate
▼
[Browser paints] → screen shows "Count: 1"
▼
useEffect callbacks run
Enter fullscreen mode Exit fullscreen mode
Because of the scheduling, a large render could have been spread across many frames, never blocking the main thread for more than a few milliseconds — keeping the UI silky smooth the whole time.
Two cheat-sheet tables
Fiber flags
| Flag | Meaning |
|---|---|
NoFlags |
Nothing needs to happen during commit. |
Update |
Existing DOM/state/ref work must be updated. |
Placement |
A node must be inserted into the DOM. |
Deletion |
A node must be removed from the DOM. |
Phases
| Phase | Interruptible? | What happens? | Side effects? |
|---|---|---|---|
| Render | Yes | Build the work-in-progress tree, run components, reconcile children, mark flags. | Should be pure. |
| Commit | No | Apply DOM mutations, swap trees, run layout effects and refs. | Yes. |
| After paint | Scheduled separately | Run passive effects: useEffect. |
Yes, but not blocking paint. |
Key takeaways
- Fiber is a reconciler rewrite, not a user-facing API. You write the same components; the engine underneath changed.
- It turns rendering into incrementally processable units of work, connected by a linked list you walk iteratively.
- It introduces a two-phase model: an interruptible, pure render phase and a synchronous commit phase.
- It enables cooperative scheduling with priority lanes, so React can yield to the browser and let urgent updates jump the queue.
- It's the backbone of Concurrent React: Suspense, transitions,
useDeferredValue, and selective hydration.
Next time you're puzzling over a strange re-render, or wondering why a heavy state update isn't freezing your input, remember:
That's Fiber in the background, chopping the work into little pieces and keeping your UI alive.
Want to see it?
Put a console.log in the component body — fires during render — and another in a useEffect — fires after paint.
Click the button and watch the order.
Then open the React DevTools Profiler and look at the commit timings.
The abstract steps above turn into concrete, observable events — and that's when it really clicks.
Written by Alex Korzh. I'm currently open to Product Engineer, Senior Frontend Engineer, and Frontend Lead roles. If your team is building complex React/TypeScript products and needs strong frontend ownership, architecture thinking, and product execution, let's connect on LinkedIn.












