from setState to pixels

2026/04/14
frontendreactbrowserrendering

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 key prop 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 getSnapshotBeforeUpdate here 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, setState reuses 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:

  1. Transitions
  2. User Agent + !important
  3. User + !important
  4. Author + !important
  5. Animations
  6. Author
  7. User
  8. 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: bordernone, margin0.
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.

TermWhat it means
React render phaseRuns the component function, produces a Virtual DOM. The browser is not involved.
Browser Render TreeDOM + 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 scheduleUpdateOnFiber all the way to commitRoot. 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

Critical Rendering Path

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.