{}The Interview
Handbook

Tracks / Frontend

From HTML bytes to a painted pixel

staffDeep dive 9 sections · 9 min read browserrenderingperformanceinternals

Questions in this set 9
  1. 01What are the stages, and which thread runs each?
  2. 02What is render-blocking, and why do scripts and stylesheets behave differently?
  3. 03What does the style stage actually do?
  4. 04Why is layout the dangerous stage?
  5. 05What is a layer, and why does transform avoid layout and paint?
  6. 06What is the frame budget, and what is the difference between the main thread and the compositor?
  7. 07How do the Core Web Vitals map onto this pipeline?
  8. 08What is the difference between the page lifecycle events?
  9. 09Where do hydration and Server Components fit?

Frontend performance advice is a list of rules — animate transform, avoid layout thrashing, use content-visibility. Every rule is a consequence of one pipeline. Learn the pipeline and you can derive the rules, and answer the ones nobody wrote down.

01

What are the stages, and which thread runs each?

text
bytes → tokens → DOM ─┐
                      ├→ Style → Layout → Paint → Composite → pixels
CSS → CSSOM ──────────┘
      [ ------------ main thread ------------ ]  [ compositor + raster threads ]
  1. Parse HTML → DOM. A streaming tokeniser builds the tree incrementally, which is why a partially-downloaded page can render.
  2. Parse CSS → CSSOM. Every stylesheet must be parsed before the first render, because painting an unstyled tree would flash.
  3. Style. For every element, resolve which declarations apply and compute a final value for every property. Output: computed styles.
  4. Layout (reflow). Compute geometry — the position and size of every box. Output: a box tree with coordinates.
  5. Paint. Turn boxes into ordered draw commands ("fill this rect, draw this text run") across paint layers.
  6. Composite. Split into layers, rasterise them (often on the GPU, on raster threads), and assemble the frame with transforms applied.

The critical division: stages 1-5 run on the main thread — the same thread as your JavaScript. Compositing does not. That single fact explains most of frontend performance.

02

What is render-blocking, and why do scripts and stylesheets behave differently?

The browser will not paint until it has both the DOM and the CSSOM, so CSS is render-blocking by default. A 200 KB stylesheet on a slow connection delays first paint by its entire download time, regardless of how fast your HTML arrived.

Scripts are parser-blocking: a classic <script> stops HTML parsing, because the script may call document.write or query the DOM built so far. Worse, a script must also wait for any pending stylesheets before it executes, since it might read computed style. So a slow CSS file can delay a script which delays parsing which delays everything.

html
<link rel="stylesheet" href="app.css">                    <!-- render-blocking -->
<link rel="stylesheet" href="print.css" media="print">    <!-- NOT blocking: media doesn't match -->
<script src="a.js"></script>                              <!-- parser-blocking -->
<script src="b.js" async></script>                        <!-- runs whenever it lands, order random -->
<script src="c.js" defer></script>                        <!-- after parse, in order, before DOMContentLoaded -->
<script type="module" src="d.js"></script>                <!-- deferred by default -->

Two mechanisms worth naming because they explain otherwise-confusing behaviour: the preload scanner is a secondary parser that races ahead of the blocked main parser to discover and start downloading subresources — which is why resources injected by JavaScript are discovered late and are much slower than ones in the markup. And fonts are only requested once style has determined a font is actually needed by rendered text, which is why webfonts are discovered late by default and why <link rel="preload" as="font" crossorigin> exists.

03

What does the style stage actually do?

For each element, the engine must produce a computed value for every CSS property. Naively that is elements × selectors, which would be catastrophic, so engines:

  • Match selectors right-to-left. For .nav li a, the engine starts at every <a> and walks up, because starting from the left would require exploring every descendant of every .nav. This is why the "rightmost selector should be specific" advice existed — though in practice modern engines are fast enough that selector performance is rarely your problem.
  • Bucket rules by rightmost id, class, tag and attribute, so only plausible candidates are tested.
  • Share computed style between elements that resolve identically (style sharing caches), and invalidate narrowly rather than restyling the document.

Where style does become a real cost: very large DOMs, CSS-in-JS injecting new rules on every render (which invalidates caches), heavy use of expensive inherited properties, and — the modern one — custom properties. A var() changed on :root invalidates every element that consumes it, so animating a custom property on the root can restyle the entire page every frame. Registering it with @property and an explicit type lets the engine handle it more efficiently.

04

Why is layout the dangerous stage?

Because it is global and geometric: changing one element's width can change its siblings, its ancestors' heights and everything after it in flow. Engines do invalidate subtrees rather than the whole document, but any change to a box's size can escape upward.

The costly pattern is forced synchronous layout (layout thrashing). Layout is normally batched and deferred to just before paint, but reading a geometric property forces the browser to flush pending style and layout immediately so it can give you an accurate number:

js
// forces a synchronous layout on EVERY iteration: write, read, write, read…
for (const el of items) el.style.width = el.offsetWidth * 2 + "px";

// one layout: batch the reads, then batch the writes
const widths = items.map(el => el.offsetWidth);
items.forEach((el, i) => { el.style.width = widths[i] * 2 + "px"; });

The properties that trigger it: offsetTop/Left/Width/Height, clientWidth/Height, scrollTop/Height, getBoundingClientRect(), getComputedStyle() (for layout-dependent properties), focus(), and innerText (which is layout-aware, unlike textContent).

contain and content-visibility are the modern escape hatches. contain: layout promises the browser that this subtree's layout cannot affect anything outside it, so invalidation stops at the boundary. content-visibility: auto skips style, layout and paint entirely for off-screen subtrees — a very large win on long pages, at the cost of needing contain-intrinsic-size so the scrollbar does not jump.

05

What is a layer, and why does transform avoid layout and paint?

Paint produces draw commands; compositing assembles textures. Some elements are promoted to their own compositing layer, rasterised separately, so moving them requires only re-assembling existing textures with a new transform matrix — no style, no layout, no paint, and it can happen on the compositor thread.

That is the entire reason for the rule:

animating pipeline stages required runs on
width, top, margin, font-size style → layout → paint → composite main thread
background-color, box-shadow, color style → paint → composite main thread
transform, opacity, filter composite only compositor thread

So a transform animation keeps running smoothly even while the main thread is blocked by JavaScript — and a left animation stutters the moment anything else runs. That asymmetry is the single most useful thing to know about browser animation.

Layers are created by: 3D transforms, will-change: transform/opacity, position: fixed, video and canvas, animating transform/opacity, and overlapping a composited element. Do not promote everything — each layer costs GPU memory (width × height × 4 bytes, at device pixel ratio), and too many layers cause "layer explosion" where compositing itself becomes the bottleneck. will-change should be added shortly before an animation and removed after, not left on permanently.

06

What is the frame budget, and what is the difference between the main thread and the compositor?

At 60 Hz you have 16.7 ms per frame, and the browser needs some of it, so roughly 10 ms of usable work. At 120 Hz, 8.3 ms. Exceeding it means a dropped frame, which users perceive as jank.

One frame's main-thread sequence:

text
input handlers → requestAnimationFrame callbacks → style → layout →
  (ResizeObserver / IntersectionObserver callbacks) → paint → commit to compositor

The compositor thread handles scrolling, pinch-zoom and composited animations independently. This is why a page with a blocked main thread still scrolls — until it needs the main thread, which is exactly what a non-passive touchstart/wheel listener forces, because the browser must wait to see whether you will call preventDefault(). Hence { passive: true }, which promises you will not, and lets scrolling stay on the compositor.

A long task is any main-thread task over 50 ms. It blocks input handling, animation callbacks and rendering for its whole duration. This is what INP measures: the delay between an interaction and the next painted frame, which is dominated by whatever else the main thread was doing.

js
// break up long work so the browser can render and handle input between chunks
async function process(items) {
  for (const [i, item] of items.entries()) {
    doWork(item);
    if (i % 50 === 0) await scheduler.yield();   // or: new Promise(r => setTimeout(r, 0))
  }
}
07

How do the Core Web Vitals map onto this pipeline?

Each metric is measuring a specific stage, which is why the fixes are different:

  • TTFB — network and server, before the pipeline starts. Fix with CDN, caching, server work.
  • FCP — first paint of any content. Gated by HTML arrival plus render-blocking CSS. Fix by shrinking and inlining critical CSS, and by not blocking on fonts.
  • LCP — the largest content element painted. Usually an image or a heading. Its timeline is: discovery (was it in the initial HTML, or injected by JS?) → priority → download → decode → paint. The commonest self-inflicted failure is loading="lazy" on the hero image, which defers discovery by design.
  • CLS — layout instability: how much visible content moved without user input, scored as impact fraction × distance fraction. Caused by images without dimensions, late-loading fonts changing metrics, injected banners and ads, and content inserted above the viewport. Fixed by reserving space (aspect-ratio, width/height, min-height), font-display: optional or preloaded fonts with matched fallback metrics (size-adjust), and never inserting above existing content.
  • INP — main-thread responsiveness across the whole session: input delay (the thread was busy) + processing (your handler) + presentation delay (style, layout, paint for the update). Fix by reducing long tasks, yielding, doing less work per interaction, and — importantly — by showing something immediately even if the full update is deferred, since the metric ends at the next paint.
08

What is the difference between the page lifecycle events?

  • DOMContentLoaded — HTML parsed and deferred scripts executed. Stylesheets and images may still be loading.
  • load — every subresource finished. Often much later, and a poor proxy for "usable".
  • requestAnimationFrame — before the next paint. The correct hook for visual updates.
  • requestIdleCallback — when the main thread is free. For analytics, prefetching, non-urgent work; it may never fire on a busy page, so always pass a timeout.
  • visibilitychangehidden — the only reliable "the user is leaving" signal on mobile. beforeunload and unload are unreliable and disable the back/forward cache; use visibilitychange plus navigator.sendBeacon for final analytics.

That last point matters more than it sounds: the back/forward cache freezes the entire page (DOM, JS heap) so navigating back is instant. Registering unload (and some beforeunload usage), or holding an open WebSocket or IndexedDB transaction, makes a page ineligible — a measurable real-world regression that is invisible in a lab test.

09

Where do hydration and Server Components fit?

Server-rendered HTML paints early — good FCP and LCP — but is inert until JavaScript arrives, parses, executes and attaches listeners. Hydration walks the whole component tree on the main thread, which is a long task at exactly the moment the user first sees content and tries to interact. That gap is why an SSR page can score well on LCP and badly on INP, and why "it looked ready but nothing worked" is a real user experience.

The mitigations, all attacking the same pipeline: ship less JavaScript (Server Components send no component code at all); selective and progressive hydration, so React hydrates in priority order and can be interrupted; islands (Astro, Fresh) hydrating only interactive fragments; and resumability (Qwik), which serialises the listener state into the HTML so there is no hydration pass at all.