Critical rendering path
What happens between typing a URL and seeing pixels
This is the most common browser question in interviews. The floor version, in order:
- Network: DNS lookup → TCP handshake → TLS negotiation → request → HTML starts streaming back. The browser doesn't wait for the whole document. It parses as bytes arrive, and the first ~14KB packet is disproportionately valuable.
- Parse: HTML is tokenized into the DOM tree. CSS, when it arrives, becomes the CSSOM.
- Render tree: DOM + CSSOM combine, visible nodes only.
- Layout: compute every box's size and position.
- Paint: turn boxes into pixels, then composite the result to the screen.
Each step consumes the previous one's output. "Critical rendering path" names the minimum chain standing between bytes and the first render; optimizing it means shortening that chain.
Does CSS block rendering or parsing?
While a stylesheet downloads, the parser keeps building the DOM, but the browser paints nothing. It prefers a blank screen to a flash of unstyled content that reflows violently when styles land.
❌ "The parser stops at <link rel=stylesheet> and waits". No. Parsing continues; rendering waits. Stopping the parser is what classic scripts do.
Meanwhile the preload scanner, a lookahead parser, keeps discovering and downloading src/href resources further down the document even when the main parser is blocked. So a blocked parser rarely means blocked downloads.
How async and defer change script loading
A classic <script src> halts the parser until the file downloads and executes, because the script might rewrite the document the parser is mid-way through. Three escape hatches:
<script src="a.js"></script> <!-- parser halts: download + execute -->
<script defer src="b.js"></script> <!-- parallel download; runs after parsing,
in document order, before DOMContentLoaded -->
<script async src="c.js"></script> <!-- parallel download; runs the moment it lands,
maybe mid-parse, order = network order -->
<script type="module" src="d.js"></script> <!-- deferred by default -->
Rules of thumb: defer for your app code (needs the DOM, needs order), async for independent things like analytics (order-free, DOM-free), type=module already behaves like defer. And ❌ async/defer on an inline script do nothing: there is no download to unblock.
display: none vs visibility: hidden in the render tree
display: none removes the element (and its subtree) from the render tree: the browser skips layout and paint for it entirely. visibility: hidden keeps the element in the render tree: it gets layout, occupies space, and only skips paint. One exits the pipeline at the render-tree step, the other only skips paint.
When something loads slow, name the blocker precisely. Slow CSS means a late first paint. A slow synchronous script in the head delays everything, because the DOM itself stops growing. The middle notes cover which of the five steps re-run when the page changes.
Which CSS properties trigger layout, paint, or composite
After load, every visual update runs some prefix-to-suffix of: JS → Style → Layout → Paint → Composite. Performance work here comes down to which stages you can skip:
.move-expensive { left: 200px; } /* layout + paint + composite */
.move-medium { box-shadow: 0 0 8px #000; } /* paint + composite */
.move-cheap { transform: translateX(200px); } /* composite only */
- Geometry (
width,height,top/left,font-size, DOM insertion, viewport resize) → full pipeline. Layout, a.k.a. reflow, is the expensive stage, and its cost scales with how much of the tree the change can reach. - Appearance (
color,background,box-shadow,visibility) → repaint without reflow. transformandopacity→ composite-only: the element's already-painted layer is repositioned or faded on the compositor thread. It skips layout and paint, and it keeps animating even while the main thread is busy. This is why production animation code sticks to transform and opacity.
❌ "position: absolute takes it out of the flow, so animating left is cheap". Out of flow is not out of layout. left is geometry; it reflows every frame.
What causes forced synchronous layout and layout thrashing
The browser batches your style writes; mutating style.width fifty times costs one layout at the next frame. What breaks batching is a geometry read after a pending write: offsetWidth/offsetHeight/offsetTop, getBoundingClientRect(), getComputedStyle(), scrollTop. The browser can't return stale geometry, so it runs layout immediately, synchronously, inside your JS.
Interleave the two in a loop and you get layout thrashing, one full layout per iteration:
// ❌ N forced layouts
for (const el of items) {
el.style.width = el.parentNode.offsetWidth / 2 + "px"; // read → flush, write → dirty, repeat
}
// ✅ one layout: all reads, then all writes
const widths = items.map((el) => el.parentNode.offsetWidth);
items.forEach((el, i) => { el.style.width = widths[i] / 2 + "px"; });
The mental model to keep: writes invalidate, reads flush. In a Performance trace this looks like a stack of purple Layout slices inside one handler, each carrying a "forced reflow" warning pointing at your exact line. Batch reads before writes, move visual work to requestAnimationFrame, and treat "measure after mutate" as a code-review flag.
When will-change helps and when it hurts
will-change: transform (or the legacy transform: translateZ(0)) promotes an element to its own compositor layer, so a later animation skips paint. But every layer costs GPU memory and upload bandwidth. Promote the elements that animate; blanket will-change on a component library is a regression, especially on low-end devices.
Loading-side levers
Still on the critical path from the junior story, now with tools:
media="print"(any non-matching media) → stylesheet downloads without render-blocking.rel="preload" as="font" crossorigin→ hands the preload scanner resources it can't see, like fonts referenced inside CSS;preconnectwarms third-party connections.- The sneaky one: a classic script must wait for the CSSOM before executing (it might read styles). So a slow stylesheet ahead of a parser-blocking script stalls the parser indirectly. ❌ "CSS never affects parsing" is false in exactly this case.
When per-frame tricks stop being enough, the structural tools cap rendering cost outright: compositor layers, contain, and content-visibility. The senior notes cover them.
Layer economics
A promoted layer buys you paint-free, main-thread-independent animation. It costs a rasterized texture in GPU memory plus upload bandwidth every time its content repaints. The failure mode is concrete: promote a hundred cards "for smoothness" and a mid-range phone runs out of GPU memory and starts thrashing textures, slower than no layers at all.
Every layer in the Layers panel should have a reason you can name: it animates transform/opacity, it's a video or canvas, or it overlaps one that does. Anything else is will-change cargo-culting. Promotion also happens implicitly (3D transforms, opacity with animation, position:fixed in some engines), so audits find layers you never asked for.
How containment and content-visibility cap rendering cost
Per-frame discipline (transform-only animation, no forced reflow) bounds the cost of a change. Containment bounds the cost of a subtree:
.widget { contain: layout paint; } /* its layout/paint can't leak out: invalidations stay inside */
.feed-item {
content-visibility: auto; /* off-screen: skip style, layout AND paint entirely */
contain-intrinsic-size: auto 320px; /* placeholder height so the scrollbar doesn't lie */
}
content-visibility: auto is the closest CSS gets to virtualization. Real-world cases go from hundreds of ms of rendering to tens on long pages, and the content stays in the DOM, in find-in-page, and in the accessibility tree (which DOM virtualization sacrifices). Without contain-intrinsic-size you buy the speed with scrollbar jumps, and careless sizing turns those jumps into a CLS problem.
How the rendering path maps to LCP, INP, and CLS
Interviewers increasingly probe the critical rendering path through metrics, so wire them together:
- LCP covers the loading-side path: render-blocking CSS, fonts without
preload, synchronous head scripts. If LCP is 4s and TTFB is 200ms, the gap is the critical rendering path (plus resource-load delay of the LCP element itself). - INP covers the update-side path: an interaction pays JS + style + layout + paint before the next frame can present. Forced reflow inside a click handler, or a state change that relayouts a 10k-node tree, lands directly on INP. Containment and smaller invalidation scopes are INP work.
- CLS is layout happening late: undimensioned images (reserve space with width/height attributes), injected banners, font swaps.
Profile before you optimize
Claims about rendering cost are checkable in one Performance recording: purple Layout slices with the forced-reflow warning attribute the flush to your exact call site; long Style/Layout blocks after an interaction tell you invalidation scope is the problem; paint flashing shows what repaints; the Layers panel shows the memory bill. Record the interaction, see which pipeline stage dominates, and only then pick a fix.
Budget honesty: at 60Hz you have ~10ms of usable frame time after browser overhead, and 120Hz displays halve it.
Architecture takes
- Keep critical CSS minimal and inline-able; everything else loads non-blocking. Render-blocking bytes in the head delay first paint.
- Standing rule, enforceable in review: user-visible motion is transform/opacity; geometry animation needs a written excuse.
- Ban measure-after-mutate by construction: batch reads/writes behind a utility or rAF phases rather than by vigilance.
- Long lists:
content-visibilityfirst (cheap, accessible), virtualization when DOM size itself (memory, hydration, style recalc) is the problem. - Third-party scripts default to
async(ordeferif they touch the DOM); anything that must block gets an explicitblocking="render"so the cost is visible in code.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.