DEV Community

Cover image for What the heck is React Fiber anyway?
Amal Shajan
Amal Shajan

Posted on

What the heck is React Fiber anyway?

Like many developers of my era, I got into programming by building web pages. By 2015 those simple pages had grown into bit more complex web apps, and that is when JavaScript started fighting back. Callbacks firing in an order that made no sense, setTimeout(fn, 0) somehow fixing bugs, code that worked fine until it suddenly didn't.

I got tired of patching around these gotchas and decided to understand the language properly. The one thing that helped me the most was a talk by Philip Roberts called What the heck is the event loop anyway? It is 11 years old now and still the best explanation of how JavaScript actually runs. It didn't dumb anything down. It just showed the machinery slowly enough that it stopped being magic.

React Fiber has the same problem today that the event loop had back then. The term gets thrown around everywhere, but every article I found told me the same three things: a fiber is a "unit of work", it lets React "pause and resume rendering", and it made React faster.

And then the article ended.

I kept collecting these quotes and never got the complete picture, at least not from a single article or video. What is a fiber? An object? A thread? A design pattern? And how does JavaScript, a language where a running function cannot be stopped, "pause" in the middle of rendering?

So this article is my attempt to do for React Fiber what that talk did for the event loop.

By the end you will know what a fiber literally is, how React walks the tree one fiber at a time, how the diffing actually works, and why the whole thing was rewritten in the first place. Fair warning, this is a long one. Grab a coffee.

PS: All the code in this article is simplified pseudocode based on the real React source. The real thing has more edge cases, but the shape is the same.


First, the one rule of the browser

Before we touch React, we need a minute on the event loop, because Fiber only makes sense as an answer to a browser problem. If you have never watched Philip Roberts' talk, go watch it first. It is 26 minutes and worth every one of them. Here is the short version.

The browser runs almost everything on a single main thread: your JavaScript, layout, painting, responding to clicks. One thread, one thing at a time.

To feel smooth, the browser wants to paint a new frame roughly every 16 milliseconds (that is 60fps). And here is the rule that matters: once a JavaScript task starts, it runs to completion. The browser cannot paint, cannot handle your click, cannot do anything until the call stack is empty again.

So if one task takes 200ms, that is 12 dropped frames. You have felt this at least once. You type into a search box on some heavy page, nothing happens, and then a second later all your letters show up in one burst. That is not your keyboard acting up. That is a JavaScript task holding the main thread while your keypresses sit in a queue, waiting.

Keep that feeling in mind. That is the exact problem Fiber was built to fix.

The old React had a call stack problem

Now let's look at what React did before Fiber. React 15 and earlier used what is now called the stack reconciler.

When state changed, React figured out what to update by recursively walking your component tree. Conceptually it looked like this:

function renderComponent(component) {
  const children = component.render();
  children.forEach((child) => {
    renderComponent(child); // recurse until the whole tree is done
  });
}
Enter fullscreen mode Exit fullscreen mode

Plain recursion. And recursion runs on the JavaScript call stack, which means the JS engine owns the process, not React. Once that first call starts, there is no way in the language to stop halfway, let the browser paint a frame, and continue. The stack unwinds when it unwinds.

For a small tree, fine. For a big tree with thousands of components, on a mid-range phone, that recursive walk could easily blow past 16ms. Every click and keypress in the meantime just queues up behind it. That is the input lag from the last section.

The name "stack reconciler" is literally the diagnosis: the call stack was the problem.

The big idea: rebuild the call stack as data

So the React team asked a simple question: what if we stop using the call stack?

Think about what a call stack really is. It is just bookkeeping. Which function is running, what its local data is, and where to go back when it is done. There is nothing sacred about letting the JS engine keep that bookkeeping. You can keep it yourself, in plain objects, on the heap.

That is what Fiber is. A fiber is a plain JavaScript object that plays the role of a stack frame. One fiber per component instance, per DOM element, per text node in your tree. React 16 rewrote the internals so that instead of recursing, React walks these objects with a simple loop.

And once the "stack" is just objects React owns, React can stop the loop whenever it wants, remember which object it was on, hand the thread back to the browser, and pick up exactly where it left off later.

That's it. That is the whole trick. Everything else in this article is a consequence of that one move.

The name "fiber" comes from an actual computer science concept: a lightweight thread that pauses itself voluntarily instead of being interrupted by the OS. Which is exactly how React's version behaves.

So what does a fiber look like?

Let's take a look at one. Trimmed down, a fiber is roughly this:

const fiber = {
  // what this node is
  type: "div", // or your component function, e.g. App
  key: null,
  stateNode: domNode, // the real DOM node (or class instance)

  // these three pointers ARE the rebuilt call stack
  child: firstChildFiber, // go down
  sibling: nextSiblingFiber, // go across
  return: parentFiber, // go back up

  // data for this render
  pendingProps: newProps, // incoming props
  memoizedProps: oldProps, // props from the last render
  memoizedState: hookList, // your useState values live here
  flags: Placement, // what to change in the DOM later
  lanes: SyncLane, // priority of pending work

  alternate: twinFiber, // we'll get to this one
};
Enter fullscreen mode Exit fullscreen mode

A few things worth noticing.

There is no children array. Each fiber points to its first child, its next sibling, and its parent (via return, named that because it is literally the return address, like on a call stack). So a tree like this:

<App>
  <Header />
  <List>
    <Item />
    <Item />
  </List>
</App>
Enter fullscreen mode Exit fullscreen mode

becomes this linked structure:

The fiber tree: solid arrows are child and sibling pointers, dashed arrows are return pointers back to the parent

You might be wondering why bother with these pointers instead of a normal array of children. Because with child, sibling and return, React can walk the entire tree depth-first using a flat loop and a single "current node" variable. No recursion, no call stack. Go down via child. Go across via sibling. When you have neither, go up via return and try the parent's sibling.

The other thing to notice is memoizedState. Your useState values physically live on the fiber, as a linked list of hooks in call order. The fiber is the stable home that survives between renders. As a side note, this is the entire reason the Rules of Hooks exist. React matches hooks to their stored slots by call position, so the call order has to be identical on every render. But that is a whole article of its own. Today we stay on the tree.

A quick detour: the virtual DOM

Before we run the machine, we need to talk about the virtual DOM. Partly because "virtual DOM" and "fiber tree" get mixed up constantly, and partly because the diffing story doesn't make sense without it.

First, why does the virtual DOM exist at all? Because React's whole promise is declarative UI. You don't write "add this row, update that class". You describe what the entire UI should look like for the current state, on every render, and React figures out the minimal set of real DOM changes to get there.

That "figures out" part means React needs something cheap to compare. Real DOM nodes are heavy objects carrying layout and style information, so React compares plain JavaScript descriptions instead and only touches the real DOM at the end.

Those descriptions are elements. When your component runs, the JSX it returns compiles to React.createElement calls, which produce small, immutable, throwaway objects:

// <div className="row">hi</div> compiles to:
const element = {
  type: "div",
  key: null,
  props: { className: "row", children: "hi" },
};
Enter fullscreen mode Exit fullscreen mode

An element is just a description: "here is what this part of the UI should look like." No state, no reference to the real DOM, nothing durable. Your components produce a fresh tree of these on every single render and the old ones get garbage collected. That tree of descriptions is what "virtual DOM" classically refers to.

Fibers are the opposite: mutable, persistent, alive across renders. The element says what the UI should be. The fiber holds how, and with what state: the real DOM node in stateNode, your hooks in memoizedState, all the pointers.

The bridge between them is reconciliation. Every render, React takes your fresh throwaway elements and diffs them against the persistent fiber tree. That diff is what we are about to walk through.

In the modern engine, the fiber tree effectively is React's virtual DOM. It is the internal tree React diffs and keeps. When people say "virtual DOM" day to day, they usually mean the element layer on top. Knowing both layers exist clears up 90% of the confusion.

The work loop: where "pausing" actually happens

Alright. Let's run the machine. Here is the heart of React, genuinely not far off from the real source:

function workLoop() {
  while (nextUnitOfWork !== null && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }
}
Enter fullscreen mode Exit fullscreen mode

There it is, the famous "unit of work". One unit of work is simply one fiber. Process a fiber, get back a pointer to the next one, check the clock, repeat.

shouldYield() is the magic. React's scheduler gives the loop a time slice of about 5 milliseconds. When the slice is used up, or a more urgent update shows up, shouldYield() returns true, the while loop simply exits, and the browser gets the thread back to paint and handle input. nextUnitOfWork still points at the exact fiber where work stopped. React schedules a continuation (using a MessageChannel callback, which fires sooner than setTimeout), and on the next turn of the event loop, the same loop resumes from that same pointer.

No black magic, no threads. "Pausable rendering" is a while loop with an exit condition and a saved pointer. This is the part where it clicked for me, and I hope it just clicked for you too.

And performUnitOfWork is our stack-free traversal from earlier:

function performUnitOfWork(fiber) {
  beginWork(fiber); // run the component, diff children
  if (fiber.child) return fiber.child; // go down

  while (fiber) {
    completeWork(fiber); // wrap this fiber up
    if (fiber.sibling) return fiber.sibling; // go across
    fiber = fiber.return; // go up
  }
  return null; // back at the root, tree is done
}
Enter fullscreen mode Exit fullscreen mode

Two function names in there doing the real work. Let's take a look at both.

beginWork goes down the tree. For a fiber whose component is a function, it calls that function with the new props, gets back fresh elements, and diffs them against the existing child fibers (that is reconciliation, next section). If nothing relevant changed, meaning same props by reference and no pending state, it can bail out and skip the entire subtree. This bailout is exactly what React.memo and useMemo feed into. They keep references stable so the equality check passes.

completeWork fires on the way back up, once a fiber has no children left to process. This is the half nobody writes about. For a new DOM element, this is where React actually calls document.createElement and sets attributes, building the real node in memory, unattached, invisible. It also bubbles information upward. If anything in this subtree needs a DOM change, that fact propagates up through subtreeFlags, so later phases can skip clean branches entirely.

For our little tree, the order looks like this:

The traversal order: beginWork on the way down, completeWork on the way back up

And between any two of those steps, React can stop and give the browser the thread. Every arrow is a potential pause point.

Two trees: how pausing is safe

Now, a fair objection: if React pauses halfway through rendering, won't the user see a half updated screen?

No, because none of this work touches the screen. React keeps two fiber trees:

  • current is the tree that matches what is on screen right now.
  • workInProgress is the draft being built by the work loop.

Each fiber is wired to its twin in the other tree by the alternate pointer from our fiber object earlier. All the beginWork / completeWork action happens on the workInProgress tree, off to the side, reusing fibers from current wherever nothing changed. Only when the entire draft is finished does React flip one pointer at the root, and workInProgress becomes current in a single atomic step.

If you have done any graphics or game programming, you know this pattern: double buffering. Draw the next frame in a hidden buffer, flip when it is complete, and the user never sees a half drawn frame.

This is also why interruption is cheap. A half built workInProgress tree is just objects in memory that nothing on screen depends on. Pause it, resume it, or if a high priority update arrives and makes the draft stale, throw the whole thing away and start over. The user never knows.

Reconciliation: the actual diff

We have been hand-waving "diff the elements against the fibers" so far. Let's open that box, because this is where your key warnings come from.

The impossible problem React refuses to solve

Here is something most articles skip. Comparing two arbitrary trees and finding the minimum number of edits to turn one into the other is a solved problem in computer science, and the best known algorithms run in O(n³) time. For a modest app with 1,000 elements, that is on the order of a billion comparisons. Per render. Not going to happen.

So React doesn't solve that problem. It makes two bets about how real UIs behave, and those bets cut the diff down to a single O(n) pass, one walk through the tree:

  1. Two elements of different types will produce entirely different trees. So don't compare their contents at all, just replace.
  2. When list items move around, the developer can tell us which is which, via key.

If a bet turns out wrong, React doesn't crash. It just does more DOM work than strictly necessary, tearing down a subtree it could have reused. The bet is that this almost never happens in real UIs, and a decade of production React says the bet was right.

Everything below is just these two heuristics in action.

Heuristic 1: compare by type

Inside beginWork, React has the old child fibers (from current, via alternate) and the new child elements your component just returned. For each pairing, the decision is very simple.

Same type? Reuse the fiber. The stateNode (real DOM node) and memoizedState (your hooks) are kept, and the new element's props are copied in as pendingProps. For a DOM element like a div, React then diffs just the props: className changed, style.color changed, and so on. This is why your state survives a re-render even though the element describing your component was thrown away and rebuilt. The fiber persisted.

Different type? (div to span, or ProfileA to ProfileB.) React doesn't even peek inside. The old fiber and its entire subtree get torn down, components unmounted, state destroyed, and a fresh subtree is built, even if the children were 99% identical. The old fiber gets flagged for Deletion, the new one for Placement. This sounds wasteful, but it is the first bet: different type means different tree, so don't waste time comparing.

One side effect worth knowing: the diff never compares across levels. If an element moves from one parent to another, React sees a deletion in one place and an insertion in another. The DOM node and its state do not travel with it. Position in the tree is part of a component's identity.

Either way, the outcome gets recorded as flags on the fiber (Placement, Update, Deletion), a shopping list of DOM changes for later. Still nothing touching the screen.

PS: This is also why swapping a component from <div> to <section> at the top of a big subtree is more expensive than it looks. You just asked React to rebuild everything underneath it.

Heuristic 2: keys, and why key={index} bites you

Single children are easy. The interesting case is a list of siblings, where React needs to figure out which old fiber corresponds to which new element. Without help, all it can do is match by position, old item 3 versus new item 3, which falls apart the moment you insert, delete, or reorder. That is what the second bet is about. key is you telling React which item is which, so it can match by identity instead of position. The actual algorithm does two passes:

Pass 1: walk old fibers and new elements together, in order, comparing keys. As long as keys match at each position, reuse and move on. For typical updates, same list with one item's content changed, this pass handles everything and it is done.

Pass 2: the moment keys don't line up (something was inserted, deleted, or reordered), React stops, puts all the remaining old fibers into a map keyed by their key, and walks the remaining new elements looking each one up in the map. Found it? Reuse that fiber, wherever it used to sit. Not found? Create a new fiber. Anything left over in the map gets deleted.

So the key is literally the lookup handle that lets a fiber, along with its DOM node and its state, follow its item around the list.

Now watch what happens with key={index}. Delete the first item from [A, B, C] and the new list is [B, C], but B now has key 0 and C has key 1. Pass 1 happily matches new B against the old fiber at key 0, which is A's fiber, carrying A's state. React concludes "item 0 changed its content, item 1 changed its content, item 2 was deleted." Every row's state is now attached to the wrong row. This is where you get those classic bugs where you delete one item and the checkbox stays checked on its neighbour.

Stable keys from your data (an ID, not a position) let pass 2 do what it was built for. That is the entire keys story.

The commit phase: fast, atomic, uninterruptible

Everything so far, running components, diffing, flagging, is the render phase. It never touches the DOM, which is precisely why it is safe to pause, resume, or throw away.

Once the workInProgress tree is complete, React switches modes and runs the commit phase: walk the flagged fibers and actually apply the shopping list. Insert this node, update that attribute, remove those. Swap the trees. Done.

And this phase is synchronous and uninterruptible, deliberately. Pausing halfway through DOM mutations would leave the user staring at a half updated, inconsistent screen, which is the one thing this whole architecture was built to prevent. So the rule is: do all the slow, throwaway thinking in the interruptible render phase, then flush the result in one fast atomic burst. Commit is fast because all the decisions were already made. It is pure execution.

Commit actually runs three sub-phases in order, and knowing them answers a classic interview question:

  1. before-mutation: read the DOM one last time before it changes (snapshots).
  2. mutation: the actual DOM inserts, updates and deletes.
  3. layout: runs useLayoutEffect synchronously, attaches refs.

useEffect is not in that list. Passive effects are flushed after the browser paints. That is the real difference between the two effect hooks. useLayoutEffect runs inside commit, before paint, so it can measure the DOM without flicker, but it blocks the frame. useEffect stays off the critical path, which is why it is the default choice.

So the full lifecycle of every update, from your click to the screen:

The update lifecycle: Trigger, then an interruptible Render, then a synchronous atomic Commit, then Effects after paint

Priorities: who is allowed to interrupt whom

One loose end. I keep saying "a higher priority update can interrupt". How does React know what is high priority?

Every update gets tagged with a lane, one bit in a 31-bit bitmask. Discrete user input like a click or keypress lands in an urgent lane. An update you wrap in startTransition lands in a low priority transition lane. React always renders the most urgent pending lanes first.

So the interruption story goes like this. React is 400 fibers into rendering a giant filtered list (transition lane). You press a key. The keypress update lands in an urgent lane, shouldYield() picks it up, the work loop exits, and React renders the small urgent update first. Your keystroke appears on screen. Then React restarts the big list render from scratch against the new state. The discarded half tree was just memory. Nobody saw anything except an input that stayed responsive.

This is also the payoff of the whole architecture. Fiber shipped in React 16 as a rewrite, but concurrency stayed off. React 18's createRoot finally switched it on, and every headline feature is this same mechanism wearing a different hat: useTransition ("this update may be interrupted"), useDeferredValue ("this value may lag behind"), Suspense ("pause this subtree until the data shows up").

Worth repeating, because it is the most common mix-up out there: Fiber is the architecture (React 16, 2017). Concurrent rendering is a feature it enables (React 18, 2022). Between those releases, Fiber rendered synchronously. The pause button existed but wasn't wired up for normal renders.

The mental model

Coffee's done. Here is the whole article in five lines:

  • The browser has one thread, and old React hogged it, because recursion on the call stack cannot be paused.
  • Fiber rebuilds the call stack as plain objects (child, sibling, return) so a flat loop can walk the tree, stop every ~5ms, and resume from a saved pointer.
  • React builds the next tree off-screen (double buffering via alternate). Pausing is safe because nothing visible depends on a draft.
  • A perfect tree diff is O(n³), so reconciliation bets its way down to O(n) with two heuristics: same type means reuse the fiber, different type means tear it down, and keys track list items by identity instead of position.
  • Render is interruptible thinking. Commit is one atomic burst of DOM changes. Lanes decide who is allowed to interrupt whom.

A fiber is a unit of work. But now you know that "unit of work" just means one stack frame that React keeps in an object, so it can put the work down and pick it back up.

Not magic. A while loop, some pointers, and one very good idea.

I hope this helped you learn something new. If you want to go deeper, the React source is surprisingly readable once you know these names. Start with ReactFiberWorkLoop.js and follow performUnitOfWork.

Top comments (0)