Mural Blog
Published on
37 min read

How React renders, and what Fiber actually does

Authors

Most articles about React Fiber were written between 2016 and 2020. React has changed a lot since then. The priority system they describe was deleted and replaced. The effect list they draw does not exist any more. The frame budget they teach was never how React worked.

This article is the version I wanted when I first tried to learn this. It goes from the beginning, in plain words, and every claim in it was checked against the React source at version 19.3.0, released on 9 September 2026.

How to read it. Parts 1 and 2 are the base. Read them even if you already know what a fiber is, because the rest of the article reuses those exact words. Part 3 and part 4 are useful to everyone. Part 5 is the deep end, and it is the part that has changed most since the articles you have already read. Part 7 is the short list of what any of this changes about the code you write.

A note on versions

Everything here describes React 19.3.0. Where something changed, I say when and give the release. Where a thing is still experimental, I say so. Where I could not verify a claim, I leave it out.

Part 1. What rendering already means

Render, commit, paint

Three words get mixed up constantly, and almost every confusion about React performance starts here.

Render means React calls your component function. That is all it means. Your function runs and returns some elements. Nothing has touched the screen yet.

Commit means React writes changes to the DOM. This only happens for the parts that actually differ from last time.

Paint means the browser turns the DOM into pixels. React does not do this and cannot control it.

A render very often ends with no DOM change at all. If your component returns the same thing it returned last time, React calls your function, compares, finds nothing to do, and writes nothing. People measure "how many times did this render" and panic. Rendering is cheap. Committing is the part that costs.

Reactthe browsertriggerrendercommitpaintsetState()calls your codewrites the DOMpixelscan pause and restartnever interruptedan effect can trigger the next render

Only the middle two steps are React's. A render that changes nothing still costs you a function call and no more.

You can watch the separation happen. Put a clock that updates every second next to a text input, type something into the input, and keep typing. The clock re-renders every second. Your text stays, and so does your cursor position. React called the whole component function each second, compared, and decided the input did not need touching.

Each render is a frozen photo

Inside one render, your props and state never change. They are fixed values, decided before your function was called.

function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <button
      onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}
    >
      {number}
    </button>
  );
}

Click it once. The number goes to 1, not 3.

The reason becomes obvious if you replace number with its value by hand. In that render number is 0, so you wrote setNumber(0 + 1) three times. Three requests to set the value to 1.

To add three, you pass a function instead. React then hands each one the latest value rather than the one your render captured.

setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);

The useful idea here is that your state does not live inside your function. Your function is a calculation that reads state from somewhere else. Later in this article that somewhere else gets a name, and it turns out there are two of them.

React has two halves

React is split into two parts, and the split is real, not conceptual.

The reconciler works out what changed. It knows about components, state, and priority. It does not know what a DOM is.

The renderer applies those changes to something. react-dom applies them to a browser DOM. react-native applies them to native views. Other renderers target canvases, PDFs, terminals and test output.

That is why react-reconciler is published as its own package, and why the same React runs on the web and on a phone. Almost everything in this article is about the reconciler, the half that has never heard of a <div>.

It is also why "virtual DOM" is a poor name. The reconciler is not modelling a DOM. It is keeping a tree of work, and one particular renderer happens to turn the result into DOM nodes.

Part 2. Why React needed Fiber

You cannot pause a call stack

Before React 16, rendering was a recursive function. React called your root component, which called its children, which called theirs, all the way down, in one unbroken stack of function calls.

Recursion is a natural fit for a tree, and it worked. It had one fatal property. A JavaScript call stack runs until it empties. You cannot stop in the middle of a recursive call, hand control back to the browser, and resume later. There is no syntax for it, and no API that grants it.

This matters because of how browsers work. The browser runs your JavaScript, and it handles clicks, typing and painting, but it does all of that on one thread. Queued work waits until your JavaScript stack is empty. So if rendering a large tree took 200 milliseconds, every click and keystroke in those 200 milliseconds simply waited.

The problem was never that React was slow. The problem was that React could not be interrupted.

recursiona loop over objectsApp()Layout()List()Row()...main thread busy, 200msa click here waits for all of itworkInProgressone variable holds the placea click fits in a gap

Both sides do the same total work. The right side just puts it down between pieces, because the place it got to is a variable rather than a stack.

A fiber is a stack frame you can put down

The fix was to stop using the call stack and rebuild it out of ordinary objects.

A stack frame holds a function, its arguments, where to return to, and a place for the result. A fiber holds exactly those things, as fields on a plain JavaScript object sitting in normal memory. Because it is an object and not a frame, React can keep a pointer to it in a variable, stop, and pick it up again later.

The comparison is not mine. It is in React's own source, in ReactInternalTypes.js, and it has survived every rewrite since 2016.

One fiber roughly means one unit of work for one component. React processes them one at a time in a loop, and between any two of them it can decide it has run long enough and hand the thread back.

What is really on a fiber

Here is the real field list, from the FiberNode constructor in packages/react-reconciler/src/ReactFiber.js at 19.3.0. React's own source groups them, and the grouping is the fastest way to learn them.

// what this fiber is
tag;
key;
elementType;
type;
stateNode;

// where it sits in the tree
return;
child;
sibling;
index;

// the work itself
ref;
refCleanup;
pendingProps;
memoizedProps;
updateQueue;
memoizedState;
dependencies;
mode;

// what to do at commit time
flags;
subtreeFlags;
deletions;

// priority
lanes;
childLanes;

// the other copy of this fiber
alternate;

Four of these deserve a sentence now, because they are the ones people get wrong.

stateNode is the real thing. For a host fiber such as a div, it is the actual DOM node. For a class component it is the instance. For a function component it is null, because there is nothing to point at.

memoizedProps is what the props were last time React finished with this fiber. pendingProps is what they are for the render happening now. Comparing those two is how React decides to skip work, and it is the whole basis of memo.

memoizedState means different things by component kind. For a class it is the state object. For a function component it is the head of a linked list of hooks. That single fact is why the rules of hooks exist, and I wrote about that side of it in Rules of hooks.

alternate points at the other copy of this same fiber. Part 3 is about why there are two.

Notice what is not in that list. There is no output field. There is no pendingWorkPriority. There is no firstEffect, lastEffect or nextEffect. Every one of those appears in articles about Fiber, and none of them has existed for years.

The tree is three pointers

This is the part that surprises people, and it explains a lot.

A parent does not hold a list of its children. It holds one pointer, called child, to its first child only. The rest of the children hang off each other through sibling. Every fiber also has return, which points back to its parent.

AppHeaderMainLogochildsiblingreturnApp.child is Header. App has no pointer to Main at all.

One child link, one sibling link, one link back up. A whole tree out of three fields per node.

So App knows about Header and knows nothing about Main. To reach Main you go to App.child and then follow sibling.

This shape is what makes pausing cheap. To remember where React got to, it stores one pointer. There is no stack to save and no list index to track. The place in the tree is the fiber.

Down with beginWork, up with completeWork

Rendering is one while loop, not a recursion. It lives in ReactFiberWorkLoop.js and it is about four lines long.

Each turn of the loop calls performUnitOfWork on whatever workInProgress points at. That does two things.

beginWork goes down. It calls your component function, works out what the children should be, creates or reuses their fibers, and returns the first child. That child becomes the next unit of work.

When there are no children left, completeWork goes up. This is where React builds the real DOM node for host fibers, through createInstance, and attaches the already-finished children to it. It does not put that node into the page. It just has it ready. Then React moves to the sibling if there is one, or climbs to the parent and completes that.

AppHeaderMainLogo1 begin2 begin3 complete4 complete5 begin6 completesolid goes down and calls your function, dashed goes up and builds nodes

Every fiber is visited twice. Once on the way down, once on the way up.

Two things here usually surprise people who have used React for years.

The first is that React builds real DOM nodes during the render phase. It creates them and keeps them detached. The render phase is not allowed to touch the document, and it does not, but the nodes are made early.

The second is the bailout at the very top of beginWork. Before doing anything, React checks whether oldProps === newProps. Not a deep comparison. The same object reference. If they match and there is no scheduled work on this fiber, React skips your component entirely and reuses the existing subtree.

That one check is why passing {children} down works so well as a performance pattern. The parent re-renders, but the children element object it received was created by its own parent and did not change, so the reference is identical and React skips that whole branch. No memo involved.

Part 3. Two trees

current and workInProgress

React keeps two fibers for every component instance, not one.

One tree is called current. It describes what is on the screen right now. The root holds a pointer to it, root.current.

The other tree is the workInProgress tree. It is the one React is building. Every fiber in it points at its twin in the current tree through alternate, and the twin points back.

React never edits the current tree. It builds the new one to the side. That single rule buys three things.

If React needs to abandon a half-finished render because something more urgent arrived, it throws the work away and the screen is untouched. Nothing to roll back.

If a render throws an error halfway through, the screen still shows the last good tree.

And when the new tree is finished and committed, React does not copy anything. It moves one pointer. root.current = finishedWork. The old current tree becomes the spare, and the next render reuses those objects instead of allocating new ones.

currentworkInProgresson the screenbeing builtAppNavListAppNavListalternateroot.current = finishedWorkone assignment, and the names have traded placesthe old current tree is now the spare, reused by the next render

React alternates between two trees forever. It does not rebuild them, and it does not throw them away.

How React decides two fibers are the same component

This is the most practically useful section in the article, because it is the cause of a bug every React team hits.

When React reconciles children, it has the old fibers and the new elements and has to pair them up. It uses two things, in this order.

The key, if there is one. The type, which is the function or the tag name.

If both match, React keeps the existing fiber. It updates the props and keeps the state, the hooks and the DOM node.

If either differs, React treats it as a different component. It deletes the old fiber and everything under it, and builds a new one. The state is gone. The DOM node is destroyed and recreated. Effects clean up and run again.

Position matters too, because React compares children at the same slot in the same parent. Which produces this, the classic surprise.

{
  isEditing ? <Input value={text} /> : <Input value={text} />;
}

Same component, same position, so React keeps the fiber and the state survives. That is usually what you want, and occasionally it is exactly what you do not want.

{
  isEditing ? <Input key="edit" /> : <Input key="view" />;
}

Different keys, so React tears down and rebuilds, and the state resets. Adding a key is the supported way to say "this is a different thing now, forget everything".

The same rule explains why key={index} in a list goes wrong. Delete the first item, and the item that used to be at index 1 is now at index 0. React sees key 0 with the same type as before, decides it is the same component, keeps its state, and just updates the props. Your checkbox ticks stay behind on the wrong rows. Use a stable id and the problem disappears, because identity now follows the data rather than the position.

What completeWork leaves behind

As completeWork climbs back up, it records what needs doing at commit time. It does this with flags, a bitmask on each fiber. One bit means "insert this", another "update this", another "run an effect here", another "detach a ref here".

Older articles describe what came next as an effect list, a separate linked list of only the fibers that need work, built through firstEffect, lastEffect and nextEffect fields.

That is gone. Those three fields do not exist on a fiber in React 19.3. You can check yourself in ReactFiber.js.

What replaced it is subtreeFlags. As each fiber completes, bubbleProperties takes the flags of all its children, ORs them together, and stores the result on the parent. So every parent carries a summary of whether anything anywhere beneath it needs attention.

At commit time React walks the tree from the top, and at each node it asks one question. Does subtreeFlags say there is anything to do below here? If the answer is no, it skips the entire subtree without looking inside.

The trade is a nice one. The old effect list was fast to walk but had to be built and maintained during render. The new approach costs one OR per node on the way up and replaces list maintenance with a single bitwise test per parent on the way down.

Part 4. Commit

The three sub-phases, and where your hooks fire

The commit phase is where React writes to the DOM, and it runs in three parts in a fixed order. This is the section to bookmark, because the order explains nearly every "why did my effect see the old value" question.

timebefore mutationmutationlayoutpaintpassiveswaproot.currentgetSnapshotBeforeUpdateDOM writesuseInsertionEffectuseLayoutEffect cleanupcomponentWillUnmountrefs detacheduseLayoutEffect setupcomponentDidMountcomponentDidUpdaterefs attachedbrowserdrawsuseEffectcleanupthen setupthis whole stretch is one uninterrupted block

useLayoutEffect runs before the browser draws. useEffect runs after. That is the entire difference, and it is why one of them can cause a visible flicker and the other cannot.

Three details in that picture are worth saying out loud.

The tree swap sits between mutation and layout. That is deliberate. It means componentWillUnmount still sees the old tree, and componentDidMount sees the new one.

Refs are detached during mutation and attached during layout. So a ref is never populated while your component function is running. If you read ref.current during render, you are reading either null or a stale value from last time.

useEffect is not part of the commit at all. React schedules it as separate work that normally runs after the browser has painted. That is what makes it the safe default. It also means that if you measure the DOM in a useEffect and then set state from it, the user can see one frame of the wrong layout. Measuring belongs in useLayoutEffect, which runs before paint and blocks it.

"The commit is atomic" is now only mostly true

For years the rule was simple. The render phase can be interrupted, the commit phase cannot.

The first half is still true. The second half has developed an asterisk.

React 19.3, released in September 2026, made <ViewTransition> stable. To animate between two states, React has to hand the DOM to the browser's view transition API and wait for callbacks. So commitRoot now passes its work through separate functions, flushMutationEffects, flushLayoutEffects and others, and a state machine tracks where it got to. While a commit is pending, hasPendingCommitEffects refuses to start new render work.

For everyday code this changes nothing. The commit still cannot be interrupted by other React work. But it can now be spread across asynchronous callbacks rather than finishing inside one synchronous block.

Part 5. Priority

This is the part that has changed most since the Fiber articles you have already read. Two pieces of folklore need clearing away before any of it makes sense.

React never used requestIdleCallback, and it does not work in frames

The story you have probably read goes like this. A screen refreshes 60 times a second, so a frame is 16.7 milliseconds. React renders for a bit, checks whether it has used up its 16 milliseconds, and if so hands the frame back to the browser.

None of that is how React works, and some of it never was.

React does not use requestIdleCallback. The team tried it in 2016, found it fired too rarely and too unpredictably, and wrote a polyfill instead. Today the scheduler posts a message to itself with MessageChannel and does its work in the resulting task. Search the React 19.3 source for requestIdleCallback and you get one hit, in a test that checks the API is absent in Node.

React also does not measure against a frame. It uses a fixed budget of five milliseconds, and it does not care where frame boundaries fall.

// packages/scheduler/src/SchedulerFeatureFlags.js
export const frameYieldMs = 5;

Five, not sixteen. React deliberately yields several times per frame rather than once. Five milliseconds of work, then let the browser do whatever it needs, then take another five.

That number is not something to tune around. It is one constant in one file, the same in the published scheduler package, and the only build that changes it is Meta's internal one.

Since React 19.2 the scheduler will also give up its slice early if something has asked for a paint, through a flag called enableRequestPaint. Interestingly, the function that requests this, requestPaint, did nothing at all in React 18. The shipped bundle contained an empty function. It became real in React 19.1.

And the reason yielding is so cheap is the thing from part 2. The place React got to is a pointer in a variable. Stopping means returning from a loop. Resuming means entering it again. Nothing is saved and nothing is rebuilt.

Lanes

Every fiber has lanes and childLanes. A lane is a single bit in a 31-bit integer.

That is the whole idea, but the reason for it is the interesting part.

The old system gave each update a number. Sync was the most urgent, then user input, then everything else. Comparing priorities meant comparing numbers, and deciding whether an update belonged in the current batch meant asking priority >= batch.

That works only if you can assume higher priority work always finishes first. Suspense broke the assumption. Once a low-priority update could be waiting on a network request while a high-priority one was ready to go, a single ordered number could not describe the situation. React needed to say "render these three unrelated things together, but not that other one", and a range cannot express an arbitrary set.

A Set would express it, and was rejected because it allocates and is slow to test. A bitmask expresses it for free.

bit 0bit 3014 transition lanes4 retry lanesIdleOffscreenSyncInputContinuousDefaultplus2 moreblocking, never slicedconcurrent, can pause and resumegetHighestPriorityLane(lanes)return lanes & -lanes;one instruction, no loop, no comparison31 bits and not 32, because the sign bit would make the integer negative

Priority stopped being a number you compare and became a set you test membership in.

Two things fall out of the change.

Membership is now lanes & batch !== 0, a single bitwise AND. Finding the most urgent pending lane is lanes & -lanes, a trick that isolates the lowest set bit in one instruction.

And one render can carry several unrelated priorities at once, which the old ordered number could never describe.

Worth knowing for arguments about release notes: lanes shipped in React 17, the release everyone remembers as having no new features. It had no new features you could see. Underneath, the entire priority system had been replaced.

The old system is still in there, as a safety net

Lanes describe priority but say nothing about time. So a lane could in principle keep losing to more urgent work forever. React calls that starvation, and the fix is the model lanes replaced.

The root holds expirationTimes, an array with one slot per lane. A function called markStarvedLanesAsExpired runs each time React schedules, and if a lane has been waiting longer than its limit, React marks it expired and renders it synchronously at the next opportunity. The limits are 250 milliseconds for sync-level work and 5 seconds for transitions.

React did not delete expiration times. It demoted them from the scheduling model to the backstop for the one case a bitmask cannot handle on its own.

Which renders actually yield, and it is fewer than you think

Here is the correction that matters most in this whole article.

"React 18 made rendering interruptible" is not true. React 18 made rendering interruptible for updates you specifically mark, and left everything else exactly as it was.

The decision lives in a function called shouldTimeSlice. It returns false, meaning render in one uninterrupted pass, when the work is in a blocking lane, when the lane has expired, or when you used flushSync.

DefaultLane is a blocking lane. A plain setState in a click handler, a fetch resolving, an effect setting state, all of it goes to DefaultLane. All of it renders in one pass and cannot be interrupted.

What does yield is a short list. Transitions. Suspense retries. Deferred values. Offscreen and idle work. That is it.

So if your app janks when a plain state update renders a big tree, concurrency will not save you. It was never running for that update.

There is a related detail that catches people reading the source. workLoopConcurrent, the loop with the shouldYield check that every article quotes, is dead code in the shipping build. It only runs when enableThrottledScheduling is on, and that flag is false in ReactFeatureFlags.js. The loop React actually runs is workLoopConcurrentByScheduler.

What concurrency cannot do

Since this is meant to be a reference, the limits deserve stating as plainly as the features.

React can stop between two fibers. It cannot stop inside one. If a single component has a 300 millisecond function body, nothing helps. The smallest unit React can put down is one component.

React cannot interrupt the commit. Ten thousand DOM insertions will block the thread no matter what lane they arrived in.

React cannot help with layout, paint, or a slow effect. Those are not its work.

And concurrency makes total render time slightly worse, not better. Yielding costs something, and doing the work in eleven pieces takes longer than doing it in one. What you buy is responsiveness during the work, not less work.

Transitions stopped blocking each other in 19.3

Until recently, all in-flight transitions were entangled. React grouped them into a single render, so one slow transition held up every other one, even completely unrelated ones.

React 19.3 turned that off. The flag is enableParallelTransitions, it is true in the shipping build, and the changelog entry says transitions now render independently.

The 2020 pull request that introduced lanes listed this exact improvement under future work. It took six years, and it arrived by removing a constraint rather than adding a feature.

One practical note. The react.dev page for useTransition still carries the old caveat saying React batches multiple transitions together. As of 19.3 that is out of date.

Part 6. The APIs, seen as lanes

Everything in this part is a public API you already use. Now that lanes have a meaning, each one becomes easy to describe.

Transitions, and the thing nearly everyone gets wrong

Here is the misconception, and it is worth stating bluntly because it is so common.

startTransition does not defer the function you give it. React calls that function immediately and synchronously. What gets marked as low priority is only the state updates that happen to be scheduled while it runs.

startTransition(() => {
  // this runs NOW, on the main thread, and blocks it
  const filtered = items.filter(expensiveFilter);

  // only THIS is marked as a transition
  setResults(filtered);
});

If expensiveFilter over a big list takes 200 milliseconds, wrapping it in startTransition buys you nothing. The filtering still happens right there and still blocks. Only the re-render caused by setResults gets the lower lane.

The fix is to move the expensive calculation out, or to make it cheaper. A transition is a label you put on an update, not a delay you put on your code.

Each transition claims its own lane, handed out round robin from the transition pool, wrapping around when it runs out. In React 19.2 that pool was split in two, with lanes 1 to 10 for startTransition and 11 to 14 for values deferred by useDeferredValue, so the two features stopped competing for the same bits.

React 19 extended this into Actions. Pass an async function and isPending stays true for the whole thing rather than just the first synchronous part. One catch survives. After an await, you are in a new task, so state updates there need their own startTransition to stay in the transition lane.

useDeferredValue

useDeferredValue looks like a debounce and is not one.

A debounce waits a fixed time and then does the work. useDeferredValue waits for nothing. It renders immediately with the old value, then starts a second render at a lower lane with the new one. If you type again before that second render finishes, React abandons it and starts over.

So there is no delay to tune, and no stale frame at the end of a pause. The work simply loses every race against your typing until you stop typing.

Two caveats worth knowing. Inside a transition it does nothing, because the update is already low priority. And passing it an object created during render defeats it, because a new object every render means it never sees the value settle.

Automatic batching

When you call three state setters in one event handler, React renders once, not three times.

Describe this as a speed optimisation and you will misunderstand it. It is first a consistency guarantee. Without batching, your UI would render in intermediate states that no single set of values ever described, and a component could see the new user with the old permissions.

React 17 batched inside React event handlers only. Updates in a setTimeout, a promise callback or a native event listener each caused their own render. React 18 made batching automatic everywhere.

One thing that matters for upgrades. Automatic batching arrived with createRoot, not with the version number. An app on React 18 or 19 that still mounts through the old ReactDOM.render does not get it. And in React 19, ReactDOM.render was removed entirely, so that decision has been made for you.

React deliberately does not batch across separate clicks, which is what stops a double click from being collapsed into one form submission.

Suspense and Activity

Both exist because a fiber tree can be kept, re-prioritised and reused rather than rebuilt.

Suspense arrived in React 16.6, a year after Fiber, and covered code splitting only. React 18 gave it streaming server rendering and selective hydration, which lets React hydrate the part of the page you clicked before the rest. React 19 changed the commit order so a fallback shows first.

Activity is newer, from React 19.2. Wrap a subtree, set it to hidden, and React keeps its state and its DOM while cleaning up its effects and dropping its updates to a low priority. A closed tab keeps its scroll position and its form contents, and pays almost nothing while hidden. This is the feature the Strict Mode remount check in React 18 was quietly preparing everyone for.

Why did my component re-render?

The question a team actually asks. Four answers cover nearly every case.

Its own state changed. The obvious one, with one exception. React compares the new state with the current one first, and if they are identical it often skips scheduling entirely. So setCount(5) when count is already 5 usually does nothing. Usually, because on the first such call React sometimes still renders once more before it settles.

Its parent re-rendered and gave it new props. Remember the bailout from part 2. React checks oldProps === newProps by reference. Any object, array or arrow function you create inline in the parent's JSX is a brand new value every render, so the check fails every time.

// new object and new function on every parent render
<Child style={{ margin: 8 }} onSave={() => save(id)} />

That is also why memo fails so often. memo does a shallow comparison of props, and a fresh object fails a shallow comparison just as surely as a deep one.

A context it reads changed. This one bypasses everything. When a provider's value changes, React walks the tree and marks every consumer, using the dependencies field on each fiber. That marking goes straight through memo, because memo compares props and context is not a prop.

The common version of this bug is a provider whose value is built inline, so it is a new object on every render of the provider, so every consumer in the app re-renders. Splitting one large context into two smaller ones is usually the real fix, not more memoisation.

It is inside a re-rendering parent and nothing stopped the walk. Which brings back the {children} pattern from part 2. Accept children as a prop instead of rendering them yourself, and the parent's re-render cannot reach them, because the element object was made higher up and its reference did not change.

One genuine breaking change: tearing

Concurrency created one problem that did not exist before, and it is worth knowing why useSyncExternalStore appeared.

If React pauses halfway through rendering and an external store changes during the pause, the components rendered before the pause read the old value and the ones after read the new one. One screen, two different values for the same data. The name for that is tearing.

It cannot happen with React state, because React controls when that changes. It absolutely can happen with a store React does not own. That is why Redux, Zustand, Jotai and MobX all had to adopt useSyncExternalStore. It gives React a way to check, before committing, whether the snapshot changed mid-render, and to start again if it did.

If you write a hook that subscribes to something outside React, use it. One rule comes with it: getSnapshot must return a cached value. Return a fresh object each call and React sees a change every time, which is an infinite loop.

Part 7. What changed, and what it means for you

What older Fiber articles get wrong

The 2016 architecture document by Andrew Clark is still the best explanation of why Fiber exists. It is also wrong about almost every name in it, and it says so itself at the top. Here is the translation table.

What older articles sayWhat is true in React 19.3
pendingWorkPriority, one number per fiberlanes and childLanes, a 31-bit mask
A lower number means a lower priorityA bitmask with no ordering at all
React uses requestIdleCallbackMessageChannel. requestIdleCallback never shipped
A 16ms frame budgetA 5ms slice, frameYieldMs, not tied to frames
An effect list of fibers that need worksubtreeFlags, one bitmask test per parent
Expiration times decide what runsKept only as the starvation backstop
Concurrent Mode, a mode you switch onNo mode. Concurrent behaviour is opt-in per update
effectTagflags
A fiber has an output fieldNo such field. completeWork builds the nodes
Fiber is an ongoing rewriteShipped as the default in React 16, September 2017
Rendering is interruptibleOnly transitions, retries, deferred and idle work
ReactDOM.rendercreateRoot. ReactDOM.render was removed in React 19

What that document is still completely right about is the shape of the thing and the reasoning behind it. A fiber is a stack frame on the heap. The tree is three pointers. There are two trees. Work happens in units. Read it for the argument, not the field names.

Fiber and the React Compiler solve different problems

React Compiler went stable in October 2025, and people reasonably ask whether it replaces any of this. It does not.

Everything in this article is runtime. React decides what to skip while it is rendering, by comparing pendingProps with memoizedProps on a fiber.

The compiler moves that decision earlier. At build time it analyses your components and writes the memoisation you would otherwise write by hand. Despite the name, it does not memoise anything at build time. It inserts caching code that runs at runtime, through a hook called useMemoCache.

It can do one thing you cannot do by hand, which is memoise conditionally, because it is writing code rather than calling a hook. And when it cannot prove a component is safe to optimise, it skips that component rather than failing the build.

So the compiler changes how often beginWork takes its bailout, and nothing else in this article. Fiber still runs underneath it.

Seeing it for yourself

Every structure described here has a name you can type into a debugger today.

Open React DevTools, select a component, and the panel is reading the fiber. The hooks it lists are that memoizedState linked list, walked and labelled.

In a development build, put a breakpoint on beginWork in ReactFiberWorkLoop.js and click something. You will see the four-function call stack from part 2, and you can step through the tree one fiber at a time.

React 19.2 added Performance Tracks to Chrome DevTools, which show a Scheduler track with the priority React is working at, split into subtracks like blocking and transition. It is the closest thing to seeing lanes with your own eyes, because lanes themselves are internal and react.dev never mentions the word.

React 19.1 added Owner Stacks, a development-only trace of which component rendered which, read through captureOwnerStack.

What this actually changes about your code

Honestly, less than the length of this article suggests. Five things.

Stop counting renders and start counting commits. A render that changes nothing is a function call.

When you wrap something in startTransition, remember the function still runs right now. Move the expensive part out of the render path, not into a transition.

Do not expect a plain setState to be interruptible. It is not, and no version of React has made it so.

Use key deliberately. It is how you tell React that a thing is a different thing, and it is the switch for resetting state on purpose.

Reach for useLayoutEffect only when you need to read or write layout before the browser paints. Everything else belongs in useEffect.

And do not tune around the 5 millisecond budget. It is one constant in React's source, it is the same for everyone, and nothing you write can move it.

Further reading

React Fiber Architecture by Andrew Clark is the original 2016 design document and still the clearest statement of the problem. Read it for the reasoning and use the table above for the names.

A deep dive into React Fiber on LogRocket was updated in July 2026 to cover React 16 through 19, so its later sections are current. Its source walkthrough is still from the React 16 era, which is where the frame budget and the output field come from.

Render and Commit on react.dev is the official version of part 1 of this article, and the best place to send someone who is new. Its neighbours on state as a snapshot and on preserving and resetting state are just as good.

React Fiber Architecture by xubh walks the data structures carefully and is worth reading alongside the source.

For the parts that change, skip the articles and read the React changelog and packages/react-reconciler/src directly. It is more readable than its reputation suggests, and it is the only source that is never out of date.

Thank you for reading, I hope that this post was useful for you.