Under the Hood
Rendering

Reflow, repaint, and rendering performance

The critical rendering path from the first lesson doesn't run once and stop — it re-runs, in whole or in part, on every change after the first frame — and rendering performance turns out to mean two separate disciplines, triggering as little of that re-run as possible and not blocking the very first one.

Reflow, repaint, and rendering performance

Six lessons ago, this module opened with a pipeline: bytes to DOM and CSSOM, DOM and CSSOM to render tree, render tree to layout, layout to paint, paint to composite. It's easy to picture that as a one-time startup sequence — the browser runs it once, produces a frame, and you're done. That picture is wrong in a way that matters for almost everything you'll do as a developer: the page keeps changing after the first frame, your code causes most of those changes, and each one re-runs some part of this same pipeline. Rendering performance, as a subject, is really just the question of how much of the pipeline re-runs on each change, and whether you're accidentally forcing it to re-run at the worst possible moment.

This capstone lesson covers two things. First, the three tiers of cost a change can fall into, depending on which stage of the pipeline it invalidates. Second, everything about when that pipeline runs — how you can accidentally force it early, how to keep it from blocking the very first frame, and how to measure whether any of this is actually a problem on your page.

Three tiers, one hierarchy

Every change that touches the page falls into one of three cost tiers, and the tier is decided entirely by which stage of the pipeline the changed property is an input to.

Reflow (layout re-runs) is the most expensive tier, because it's the one the render tree and layout lesson already flagged as inherently a whole-subtree computation — a box's size and position can depend on its children and can shift every box after it in the flow, so a single geometry-affecting change can ripple outward. Layout re-running always forces paint and composite to re-run after it, because their inputs are now stale too.

Repaint is the middle tier: the changed property doesn't touch any box's geometry, so layout can be skipped entirely, but the box's actual appearance did change, so paint has to redraw its pixels before composite can assemble the new frame.

Composite-only is the cheap tier: neither geometry nor the painted bitmap changes, only how an already-painted layer gets placed or blended during the final assembly — which is why it can happen on the compositor thread without touching the main thread at all.

This is the exact same hierarchy the layout, paint, composite lesson works through property by property, with a full property table, and the exact same handoff the compositor thread lesson explains from the threading side. What this lesson adds is the framing: these three tiers are not a separate topic from the critical rendering path — they're a precise answer to "how much of the critical rendering path re-runs" for a given change, which is the piece the flagship lesson deferred to here.

Forcing the pipeline to run early: forced synchronous layout

The three-tier picture assumes the browser gets to batch its own work and run layout once, on its own schedule, per frame. That assumption breaks the moment your own code reads a layout-dependent property immediately after writing a style change. Properties like offsetHeight, offsetWidth, getBoundingClientRect(), and getComputedStyle() (for a geometry property) can only be answered correctly with up-to-date layout — so if layout is currently stale because of a write you just made, the browser has no choice but to run layout synchronously, right then, instead of waiting for its normal turn.

// Forces layout twice, synchronously, once per iteration —
// each read can't be answered from cached geometry because the
// write right before it just invalidated the previous layout.
for (const el of elements) {
  el.style.width = el.offsetWidth + 10 + "px"; // write, then...
  console.log(el.offsetHeight);                 // ...read, forcing layout now
}

Do this in a loop and you get layout thrashing: the same synchronous layout computation running dozens or hundreds of times in a single task, each one interleaved with a style write, instead of once. This is exactly what the layout thrashing lesson dissects in full — the mechanics of why the read forces the recompute and a longer catalog of which properties trigger it — so treat this section as the pointer, not the whole story.

Batching: separate the reads from the writes

The fix follows directly from the cause: never interleave a layout-triggering read with a layout-triggering write. Do every read first, then every write, so the browser only has to compute layout once for the whole batch instead of once per element.

// Read phase: gather everything first, using layout that's already valid.
const widths = elements.map((el) => el.offsetWidth);

// Write phase: no reads interleaved, so no forced synchronous layout —
// the browser batches all these writes and lays out once, on its own schedule.
elements.forEach((el, i) => {
  el.style.width = widths[i] + 10 + "px";
});

The other half of batching is when you drive visual updates at all. Wrapping DOM writes in requestAnimationFrame lines them up with the browser's own rendering step in the event loop, rather than firing at an arbitrary point in an unrelated task. Batching DOM writes with requestAnimationFrame and Rendering in the loop both cover exactly where that rendering step sits relative to your JavaScript, microtasks, and the next paint — worth reading if "run this on its own schedule" still feels vague.

Optimizing for the first frame, not just later ones

Everything above is about changes after the first paint. But the critical rendering path also has a one-time cost at the very start — the first walk from bytes to pixels — and that cost is dominated by render-blocking resources, not by anything covered in the three-tier model.

Minimize and inline critical CSS. The flagship lesson established that CSS blocks the render tree, and the render tree blocks the first paint — so the CSS needed for above-the-fold content is on the critical path by definition. Inlining just that CSS directly in the document removes a network round trip from the very first render.

<head>
  <style>
    /* Inlined: only what's needed to render the visible-on-load content.
       No extra request, no render-blocking wait on this CSS specifically. */
    body { margin: 0; font-family: system-ui, sans-serif; }
    .hero { min-height: 60vh; background: var(--paper-deep); }
  </style>

  <!-- Deferred: this stylesheet only matters at narrow widths, so it's
       fetched with low priority and doesn't block rendering at all —
       the media attribute makes it non-render-blocking until it matches. -->
  <link rel="stylesheet" href="narrow.css" media="(max-width: 480px)" />

  <!-- Preload: tells the browser to start fetching a resource it would
       otherwise only discover much later in the document, without
       waiting for the parser to reach the tag that uses it. -->
  <link rel="preload" href="hero.woff2" as="font" type="font/woff2" crossorigin />
</head>

The media attribute matters here beyond responsive design: a stylesheet whose media query doesn't currently match is fetched (so it's ready if the viewport changes) but does not block the first render, because the browser knows it can't possibly apply to the current layout. preload is the opposite tool — it doesn't defer anything, it accelerates a fetch the parser would otherwise discover late, which matters for a resource like a critical font or hero image buried past a lot of other markup.

Don't forget the script side of this. The DOM parsing lesson already covered why a plain synchronous <script> blocks tree construction, and by extension delays the render tree behind it — defer and async are the opt-outs, and they belong in this optimization list exactly because a blocked parser is a blocked first paint.

FOUC and FOIT. Get the CSS-blocking order wrong — say, by loading a web font asynchronously with no fallback strategy — and you get one of two visible failures: a flash of unstyled content (FOUC), where the page briefly renders with browser defaults before the real stylesheet arrives, or a flash of invisible text (FOIT), where text is held invisible until a custom font finishes downloading. Both are symptoms of the same underlying cause as the head/defer rules: something the render tree depends on wasn't ready when the browser wanted to paint.

Skip rendering work you don't need yet. content-visibility: auto tells the browser it can skip layout and paint for an element's contents entirely while that element is off-screen, and do that work only once it's about to become visible — a direct way to shrink the amount of the pipeline that has to run on initial load for a long page.

/* Off-screen sections skip layout and paint until they're
   about to scroll into view, shrinking the initial rendering cost
   on a long page without hiding the content from find-in-page or
   accessibility tools. */
.article-section {
  content-visibility: auto;
  contain-intrinsic-size: 1px 800px; /* placeholder size so scrollbars don't jump */
}

The related contain property (contain: layout / contain: paint / contain: strict) makes a narrower promise — that an element's internal layout or paint won't affect anything outside its own box — which lets the browser scope a reflow or repaint to that subtree instead of worrying it might ripple further, tying directly back to the "layout ripples" problem from the render-tree lesson.

Measuring instead of guessing

None of the above is worth doing blind. Core Web Vitals give you the standard vocabulary for what to measure: LCP (Largest Contentful Paint) times how long the biggest above-the-fold element takes to render — directly a function of the critical-path optimizations above — and INP (Interaction to Next Paint) measures how responsive the page stays to input after load, which is where forced synchronous layout and layout thrashing show up as real, user-visible lag. INP is the same territory the event loop module's jank-and-responsiveness lessons cover from the scheduling side; this lesson explains why a slow interaction is slow (it's stuck re-running layout and paint), the event loop module explains why the browser couldn't get to it sooner.

Chrome DevTools' Performance panel records a timeline where Layout, Paint, and Composite each show up as their own labeled blocks — the fastest way to confirm which tier a given interaction actually falls into rather than guessing from the CSS. Lighthouse runs an automated audit and reports Core Web Vitals alongside specific, actionable flags — "eliminate render-blocking resources," "avoid large layout shifts" — that map directly back to the concepts in this module.

The module, start to finish

Pull the whole thing together, because every lesson so far was one link in a single chain: HTML bytes are parsed into the DOM while CSS bytes are parsed into the CSSOM; selectors get matched against elements and the cascade resolves conflicts into a computed style for every element; the DOM and those computed styles combine into the render tree, which layout turns into real geometry; that geometry is painted, layerized, and composited into a frame; and that whole chain re-runs, in whole or in part, on every change afterward — which is the subject of this lesson.

Zoom out one more level and this pipeline is itself just one step that happens inside the browser's event loop, squeezed into whatever's left of the 16ms frame budget after your JavaScript runs. The rendering module tells you what runs; the event loop module tells you when; the animation module tells you how much time it has. All three describe the same machine from different angles, and if you've read all of them, there's genuinely no part of "why is my page slow" left that's a mystery rather than a mechanism you can point to.

Go deeper

  • web.dev — Optimize Largest Contentful Paint A concrete checklist for the first-paint optimizations this lesson covers — critical CSS, preload, render-blocking resources — tied to the LCP metric.
  • MDN — Reflow The reference definition of reflow this lesson's first tier is built on, including which operations tend to trigger it.
  • MDN — Using CSS containment The full semantics of content-visibility and contain — exactly how much rendering work each one lets the browser skip.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Name the three cost tiers a change can fall into and, for each, which stages of the pipeline actually re-run.
  2. Why does reading offsetHeight immediately after a style write force layout to run synchronously, instead of on the browser's normal schedule?
  3. What's the fix for layout thrashing in a loop that both reads and writes layout properties, and why does reordering the operations solve it?
  4. Why does a stylesheet with a non-matching media query not block the first render, even though the browser still fetches it?
  5. Explain FOUC and FOIT as two different symptoms of the same underlying dependency problem.
  6. What does content-visibility: auto let the browser skip, and for what kind of page does that matter most?
  7. How do the rendering pipeline, the event loop's rendering step, and the animation module's frame budget relate — what question does each one answer about the same underlying machine?