from setState to pixels
You call setState. You think you're updating the screen. You're not. Not yet.
What you've done is schedule a rerender. React queues it, decides when to run it, and hands the result to the browser. The browser still has to do its own work before anything changes on screen. That handoff involves nine steps. Most frontend developers know two of them.
Here is the whole loop up front, so the nine steps have somewhere to land. React owns the first three and the last; the browser owns the five in the middle, the same work it does with or without a framework.
setState
→ render ┐
→ commit │ React
→ useLayoutEffect ┘
→ Style Recalc ┐
→ Render Tree │
→ Layout │ browser
→ Paint │
→ Composite ┘
→ useEffect React
I. The render phase
React runs your component function again. The function returns a new tree of JavaScript objects: the Virtual DOM. The browser has no idea this exists. It's just memory.
React compares the new tree against the previous one. Two rules govern the diff:
- Different element type? Tear down the old subtree, build a new one.
- List items? The
keyprop tells React which item is which across renders.
In React 18, this phase can be paused and restarted. With startTransition, you mark an update as low priority. If the user does something more urgent before it finishes, React drops the partial render and starts over with the new state. Nothing has touched the DOM yet, so throwing away ongoing work is safe.
const [input, setInput] = useState('')
const [query, setQuery] = useState('')
function handleChange(e) {
setInput(e.target.value) // high priority. update input immediately
startTransition(() => {
setQuery(e.target.value) // low priority. React can interrupt this render
})
}
// <ExpensiveList> rerenders with query.
// If the user types again before it finishes, React drops
// the ongoing render and restarts with the latest query.Without startTransition, the entire render runs synchronously. The input lags until the list finishes.
II. The commit phase
The diff is ready. React writes it to the real DOM.
This phase cannot be interrupted. React runs it to completion. Once it starts, the DOM is changing.
Internally, commit runs three stages:
- Before mutation: snapshot the state before the DOM changes. Class components use
getSnapshotBeforeUpdatehere to record things like scroll position. - Mutation: actual DOM writes.
insertBefore,removeChild,setAttribute. - Layout: attach or detach refs, fire
useLayoutEffect. So "useLayoutEffect runs after commit" really means it runs inside the last of these three.
III. useLayoutEffect
Before the browser renders anything, React fires useLayoutEffect synchronously.
This is the only moment you can safely read layout: getBoundingClientRect, offsetHeight, scroll position. You're reading after the DOM write, before the paint. If you read layout in useEffect instead, you're reading after the browser has already rendered once, which means you'll trigger a second render to correct it.
Reading layout here is synchronous and blocking: getBoundingClientRect() computes style and layout on that line, no Promise, no await to warn you. A read forces that compute only when layout is dirty, and a write dirties it. One read is one pass:
read → style recalc → layout
Interleave writes and each one re-dirties, so the pass runs again and again (layout thrashing):
read → style recalc → layout → write → style recalc → layout → write → style recalc → layout
Fix: batch all reads, then all writes. For pure measurement, ResizeObserver / IntersectionObserver report asynchronously, no forced layout.
useLayoutEffect blocks the paint. Keep it fast.
Now the browser takes over.
CRP, technically. People call this whole pipeline (Style Recalc through Composite) the Critical Rendering Path. Strictly, CRP is first paint only. After that,
setStatereuses the same pipeline. Same steps, different context.
IV. Style Recalc
The browser notices the DOM changed and needs to figure out what every element should look like. A value moves through six stages before it reaches the screen:
declared → cascaded → specified → computed → used → actual
Style Recalc runs the first four (the steps below). Used and actual finish later, in Layout and Paint.
Collecting. The browser gathers styles from three sources: its own builtin defaults (User Agent), any preferences the user has configured in their browser, and your stylesheet (Author).
Cascading. All collected declarations compete. The browser resolves conflicts in this order, highest priority first:
- Transitions
- User Agent +
!important - User +
!important - Author +
!important - Animations
- Author
- User
- User Agent
Within the same origin, specificity breaks the tie: inline styles beat IDs, IDs beat classes, classes beat type selectors. If specificity is equal, the declaration that appears last wins. The reference below shows every selector's exact score and what !important does to the whole system.
Defaulting. Properties with no declared value still need one. Two ways to fill it:
Text-ish properties inherit from the parent: color, font-size, font-family.
Box-ish properties fall back to a spec default: border → none, margin → 0.
Override either path with inherit, initial, or unset.
Resolving (the easy half). First of two passes (section VI is the other). Anything that doesn't need geometry becomes concrete: em against the parent font size, rem against the root, bold becomes 700, relative paths become absolute URLs. Anything that needs the box size (50%, auto, min-content) can't resolve yet, so it waits for Layout.
Output: every node's computed style. Note computed: width: 50% is still 50% here, not a pixel.
V. Render Tree
DOM plus computed styles merge into the Render Tree. It's the same DOM with style attached, and everything invisible dropped.
display: none: excluded. No node in the tree, no space in the layout.
visibility: hidden: included. Space reserved, pixels not drawn.
<title>, <head>: excluded. The browser only builds the tree from what's visible.
VI. Layout / Reflow
The browser walks the Render Tree and calculates the geometry of every element: position, width, height. This step is also called Reflow. It's where two CSS steps finish:
Formatting. The browser organizes elements into formatting contexts: block, inline, flex, grid. Each context has rules for how its children stack and flow.
Resolving (the layout half). The second pass. 50%, auto, min-content needed a box to measure against; now it exists, so they resolve. Style Recalc gave the computed value (50%); Layout makes it the used value (400px).
Layout is expensive. Inserting a DOM node triggers it. Changing font-size triggers it. Resizing the window triggers it. Reading getBoundingClientRect mid-animation can trigger it repeatedly.
That's why useLayoutEffect fires back in section III, before this step: one place to batch your reads, right after the DOM write, before the browser reaches this geometry pass.
VII. Paint
The browser fills pixels into layers. Multiple layers, not one. It draws background, color, box-shadow, border. Each property fills pixels in its own layer.
Transforming. The browser does its final pixel level cleanup: subpixel snapping, clamping values to device limits, clipping to the viewport. (Unrelated to CSS transform: translate(), which lives in the next section.)
This step is cheaper than layout but still involves the CPU.
VIII. Composite
The GPU takes the layers from the paint step and merges them into the final frame.
transform and opacity live here. Moving an element with transform: translateX(100px) skips layout and paint entirely. Only the composite step runs. That's why CSS animations on transform or opacity are smooth even on slow hardware. They bypass the expensive steps.
Animations on left, top, or width trigger layout on every frame. That's the difference between 60fps and a jank spike.
IX. useEffect
useEffect fires after the browser has painted.
This is where side effects belong: data fetches, subscriptions, event listeners. The user has already seen the updated screen. Nothing here blocks the paint.
The naming collision
"Render" means two different things in frontend, and conflating them causes confusion in every performance discussion.
| Term | What it means |
|---|---|
| React render phase | Runs the component function, produces a Virtual DOM. The browser is not involved. |
| Browser Render Tree | DOM + CSSOM merged. Built from the real DOM React wrote in the commit phase. |
React's render phase produces JavaScript objects. The browser's Render Tree is built from real DOM nodes. They don't overlap. React finishes its render before the browser starts building its Render Tree.
The full sequence
Browser receives HTML
│
├── HTML parser ──► DOM
│ │
│ ├──► DOMContentLoaded ← DOM ready + defer scripts (NOT CSSOM)
│ │ → React.render(<App />, #root)
│ │ → render + commit React writes DOM nodes, no CSSOM needed
│ │
└── CSS fetch ──► CSSOM
│
DOM + CSSOM ─────┴──► Render Tree ──► Layout ──► Paint ──► Composite ──► First Contentful Paint
scripts <script> blocks parser, runs immediately
<script defer> parallel fetch, runs after DOM ready
<script async> parallel fetch, runs on download
<script module> same as defer + own scope
window.onload ← images, fonts, iframes all loaded
──────────────── app is live ────────────────────────────────
any setState (event handler, fetch, timer, WebSocket...)
→ setState called
┌──────────────────────────────────────────────────┐
│ render phase VDOM diff, interruptible │
│ commit phase writes to real DOM │
│ useLayoutEffect read layout, blocks paint │
│ Style Recalc cascade, specificity, compute │
│ Render Tree DOM + computed styles merged │
│ Layout / Reflow geometry: position, size │
│ Paint pixels into layers │
│ Composite GPU merges, screen updates │
│ useEffect side effects │
└────────────────────────┬─────────────────────────┘
│ any setState
└──► back to render phase
The three paint metrics. The bootstrap path above ends at FCP (First Contentful Paint), the first content pixels. Two cousins sit near it: FP (First Paint) is the first pixels of anything, a background counts; LCP (Largest Contentful Paint) is when the viewport's largest element paints, usually much later, gated on when the hero image's bytes arrive.
Nine steps. The first three belong to React. The last one belongs to React. The middle five are the browser doing what it has always done, with or without a framework. And the loop runs for as long as the app is alive.
To see how those nine steps spread across processes and threads in real time, here is the same loop laid out as a timeline:
References
React internals
- Inside React's FiberWorkLoop. Follow up post of mine. Zooms inside the "render phase" and "commit phase" bullets above, walking through the four numbered stages of React's internal reconciler workloop.
- React 技术揭秘 by Jokcy (卡颂). A simplified Chinese deep dive into Fiber, Scheduler, Lanes, and Hooks. The standard narrative reference for React at source level.
- React 技术解析 (图解系列). Another simplified Chinese resource, but function by function, with flow charts that trace the reconciler from
scheduleUpdateOnFiberall the way tocommitRoot. Complements Jokcy's book with more concrete workflow diagrams. - A Cartoon Intro to Fiber by Lin Clark, React Conf 2017. Visual walkthrough of why Fiber replaced the old stack reconciler.
Browser rendering pipeline
- Inside look at modern web browser by Mariko Kosaka. Four part series on how Chrome's processes and threads cooperate.
- How browsers work by Tali Garsiel. The long form classic on what happens from bytes to pixels.
- How web browsers work: the render tree (part 7) by arikaturika. Part 7 of an illustrated dev.to series. Walks render tree construction step by step with diagrams.
- The Anatomy of Browser Rendering by Akhil Regonda. Six step walkthrough from parse to composite, with optimization notes along the way.
- 浏览器是如何渲染网页的? by Kerb. Simplified Chinese take on DOM, CSSOM, and render tree. Explains how render-blocking CSS and parser-blocking scripts reshape the pipeline.
Critical Rendering Path
- Render Tree Construction. How DOM and CSSOM merge, what gets included, what gets dropped.
- Measure the Critical Rendering Path. How to instrument and measure CRP timing with Navigation Timing API and DevTools.
Layout and reflow
- On Layout & Web Performance by Kelly Norton. The classic post on layout thrashing. Browsers compute layout lazily, so read-write-read forces reflow every time.
- Avoid large, complex layouts and layout thrashing by Jeremy Wagner, Paul Lewis, Barry Pollard. Concrete techniques: batch reads before writes, avoid forced synchronous layout, flatten the tree.
- What is DOM reflow? Stack Overflow thread. Short canonical answer for what reflow is and what triggers it.
- Browser Rendering Optimization by Vasanth Krishnamoorthy. Checklist style notes on hitting 60fps. Covers pipeline stages, frame budget, and per step optimizations.
Extensibility
- Houdini APIs on MDN. Houdini opens the CSS engine's internal steps to userland. Paint, Layout, and Animation Worklets each hook into one step of the pipeline above. Chrome and Edge only for now.
- Web Workers vs Service Workers vs Worklets by Ire Aderinokun. Short comparison that pins down what each one does, and why only Worklets hook into the rendering pipeline.