This article is published in English.
How the Browser Paints and Where React Fits In
Learn how the Critical Rendering Path, reconciliation, Fiber, and the Scheduler work together to turn React updates into pixels on screen.
First, forget React: how does a browser actually paint a page?
Set React aside for a moment. Even a bare HTML document with a sprinkle of CSS travels through a fixed sequence of steps before a single pixel shows up. Every browser follows this sequence, no matter what tools built the page:
HTML → DOM tree
CSS → CSSOM tree
DOM + CSSOM → Render Tree
Render Tree → Layout → Paint → Composite → Screen
Each stage does something specific, and the names alone don't make that obvious:
- DOM tree — the browser parses your HTML and converts it into a tree of nodes. This is pure structure: which elements live inside which.
- CSSOM tree — the same idea applies to your styles. Every CSS rule you author gets turned into a tree the browser can look up.
- Render Tree — the browser combines the two trees: it walks through the DOM, attaches the matching CSSOM rules to each node, and skips anything that won't actually be visible (an element with
display: nonestill lives in the DOM but is excluded from the render tree). - Layout — this is the arithmetic stage. Given every element and its computed styles, the browser works out the exact position and size of each one.
- Paint — now the browser fills in the visuals: text, background colors, borders, shadows, and so on.
- Composite — when a page has multiple layers (which happens often for performance reasons), the browser stacks them in the correct order, and that final result is what actually reaches your screen.
Together these steps form what's known as the Critical Rendering Path, or CRP.
This sequence runs identically whether you're using React, Vue, plain jQuery, or no library at all. Painting pixels is the browser's responsibility, not something any UI framework takes over.
So where does React fit into all this?
This is the part that takes a moment to click. React doesn't replace the Critical Rendering Path — it operates upstream of it.
Without React, updating the UI means manually locating the correct DOM node and mutating it yourself:
const counterEl = document.getElementById('counter');
counterEl.textContent = newCount;
That's manageable for a single counter. But imagine a dashboard where forty separate values can change independently — you'd need to manually track and update each one by hand. Solving exactly that problem is React's reason for existing.
With React, the same update looks like this instead:
function Counter({ count }) {
return <p>{count}</p>;
}
You describe what the interface should look like given the current data, and you never touch the DOM directly. So something else has to do that work. That's React's actual job, and it happens as a step before the browser's Critical Rendering Path even begins:
State changes → React figures out what changed → applies a small patch to the real DOM
↓
browser does its normal thing: Layout → Paint → Composite → Screen
React's whole value proposition comes down to making that first step — figuring out what changed — as fast and precise as it can be, so the browser only has to redo Layout and Paint for the small slice of the page that actually needs it, instead of reprocessing everything.
Imperative versus declarative: the shift in thinking
This contrast explains why React was designed the way it was.
Imperative code spells out every individual step:
list.innerHTML = '';
for (const item of items) {
const li = document.createElement('li');
li.textContent = item;
list.appendChild(li);
}
You're the one deciding: wipe this out, build that element, attach it here.
Declarative code instead describes the outcome you want to see:
<ul>
{items.map(item => <li key={item}>{item}</li>)}
</ul>
Rather than instructing the browser to "create an li and insert it," you're stating "given this array, here's what the resulting UI should be." Something else has to convert that description into concrete DOM operations — and understanding what that something is comes next.
What "reconciliation" really means
This piece sounds more complicated than it actually is once you look at it directly.
React holds a mental snapshot of your UI known as the Virtual DOM. Whenever something changes, React constructs a fresh version of that tree and compares it against the previous one to figure out what's different. That comparison step is what people call reconciliation.
Checking every conceivable difference between two trees would be computationally brutal, so React takes a shortcut with two rules that keep things fast in real-world use:
- The element type changed (say a `
` turned into a ``) — React doesn't even look at the children; it discards the old node entirely and builds a new one.
- The element type stayed the same (`
becoming
`) — React keeps the existing real DOM node and just patches the parts that differ.
One trap that catches nearly everyone involves lists. By default React matches list items by their index — comparing item 0 to item 0, item 1 to item 1, and so on. If you insert a new entry at the start of an unkeyed list, React assumes every single item below it has changed too.
That's the reason you should always assign a stable key to list items:
{items.map(item => <li key={item.id}>{item.name}</li>)}
Once items carry a key, React can recognize "this particular item just changed position" rather than assuming the whole list was rebuilt.
After all this comparing, React ends up with a short, targeted set of instructions — things like "update this text node" or "insert a node here" — and those are the operations that actually get applied to the real DOM.
Isn't this the same thing Fiber does?
It's a fair question, and one worth pausing on.
Reconciliation is the underlying idea. Fiber is simply the machinery that executes it.
Prior to React 16, that machinery was called the Stack Reconciler. It traversed the entire tree recursively and synchronously, meaning once it began, it had to run to completion before stopping. For a large update, this could tie up the main thread long enough that the app felt sluggish — frames would drop, and typing could feel unresponsive.
Fiber, introduced in React 16, replaced that machinery. The concept of reconciliation didn't change, but now the work gets divided into small chunks that can be paused, discarded, or picked back up later. If something more urgent shows up — like the user typing — React can interrupt whatever lower-priority work it was doing, deal with the urgent update, and then return to where it left off.
So it's not accurate to say Fiber replaced reconciliation. It's more accurate to say the older engine that performed reconciliation was swapped out for a more capable one.
So what does the Scheduler do?
Fiber makes it possible to pause and resume work, but something else needs to decide when to pause and which task deserves priority. That's the role of the Scheduler.
Its responsibilities include:
- Ranking updates by urgency — for example, treating a keystroke in an input as urgent while a distant background list refresh is not.
- Filling in the gaps between browser frames to make incremental progress on lower-priority work, and stepping back before the next frame needs to be rendered.
- Enabling React 18 capabilities like
startTransition, where marking an update as non-urgent effectively tells the Scheduler it's free to push that work down the priority list.
A simple mental model ties these three concepts together:
Reconciliation → the algorithm (what changed?)
Fiber → the engine that makes that algorithm interruptible
Scheduler → the traffic controller deciding when to pause/resume Fiber's work
Render phase versus commit phase
There's one more distinction worth understanding: Fiber divides its work into two phases that follow very different rules.
The Render Phase is where the actual diffing takes place. React invokes your component functions, assembles the new tree, and compares it to the previous one. None of this touches the real page yet, which is why this phase can safely be paused, thrown away, or restarted.
The Commit Phase is where React finally writes to the real DOM and applies the computed patch. This phase cannot be interrupted — it runs start to finish in a single, uninterrupted pass, since a partially applied UI update would leave the page in a broken visual state. Immediately after the DOM is updated, but before the browser paints the screen, useLayoutEffect fires synchronously. useEffect, by contrast, runs somewhat later, after the browser has already completed painting.
Do you actually need React at all?
Honestly, not always. Plenty of production websites run on nothing but HTML, CSS, and plain JavaScript, and they work just fine.
React starts to justify its overhead once your requirements grow more complex:
- Manually wiring up DOM updates is manageable for small projects, but it becomes unmanageable once you're juggling dozens of interdependent UI pieces.
- A large share of real-world UI bugs come from state and the displayed UI falling out of sync with each other. React's approach — treat the UI as a function of state and let the framework handle the diffing — eliminates much of that risk by design.
- Being able to build reusable components, backed by an ecosystem of routing tools, dev tools, and shared conventions, becomes valuable once more than one developer is working on the same codebase.
For a simple landing page or a largely static site, though, plain JavaScript is the better choice. Bringing in Fiber, the Scheduler, and the full reconciliation pipeline would mean paying overhead for a problem you never actually had.
React isn't inherently superior to JavaScript. It's a toolkit built to solve one specific problem: keeping your UI synchronized with state that changes constantly, at scale, across a team. Below that scale, plain HTML, CSS, and JS handle the job perfectly well.