Walk me through what the browser does between receiving the HTML and the first paint, and tell me why layout thrashing is expensive
The browser parses HTML into the DOM and CSS into the CSSOM, combines them into a layout tree, then runs style, layout, paint and composite. Reading a geometry property after a write forces the browser to flush layout early, so a loop that interleaves reads and writes pays for a full layout every iteration.
What the interviewer is scoring
- Does the candidate distinguish render-blocking CSS from parser-blocking script instead of calling both "blocking"
- Whether the four pipeline stages are named in order with the right cost attributed to each
- That the forced layout is correctly blamed on the read, not on the write that preceded it
- Whether transform and opacity are named as the compositor-safe properties without box-shadow or width being swept in alongside them
- Does the candidate mention profiling the frame to find the layout events, rather than guessing which line is slow
Answer
From bytes to two trees
The HTML arrives as a byte stream. The browser decodes it to characters, tokenises those into start tags, end tags and text, and the tree builder turns the token stream into the DOM incrementally — the parser does not wait for the last byte, which is why a slow-streaming document can still show something early.
CSS is processed into a parallel structure, the CSSOM. It has to be separate because the cascade needs it to be: a declaration's winner depends on origin, specificity and order across every stylesheet, so no element's computed style is final until every stylesheet that applies has been parsed. The DOM and CSSOM are then combined into the layout tree (called the render tree in older material), which holds only the nodes that generate boxes. An element with display: none produces no box and is absent from that tree; visibility: hidden produces a box, occupies space, and is present.
What blocks what
These two are different failure modes and conflating them is the commonest sloppiness in the answer.
A stylesheet is render-blocking, not parser-blocking. The parser carries on building the DOM behind it, but the browser will not paint content until pending stylesheets have been fetched and parsed, because painting first would show unstyled text and then reflow it. So a stylesheet on a slow third-party host delays first paint even though the DOM was ready long ago.
A classic <script src> with no attribute is parser-blocking. The parser stops at that tag, fetches, executes, and only then resumes, because the script may call document.write. defer removes that: the fetch happens in parallel and execution is postponed until parsing is complete, in document order, just before DOMContentLoaded. async also fetches in parallel but executes at whatever moment the fetch completes, out of order, and can interrupt parsing. Module scripts are deferred by default.
The two interact in a way that catches people out. A classic script must wait for any stylesheet that is still loading, because the script might read a computed style and would otherwise see a value that is about to change. So one slow stylesheet in the head can stall a script placed well below it.
Style, layout, paint, composite
Once the trees exist, and again on every subsequent change, the browser runs a fixed pipeline.
Style resolves declarations into a computed value for every property on every element. Layout, also called reflow, works out geometry: box sizes and positions in the flow, which is inherently interdependent, since one element's width changes its siblings' positions. Paint rasterises the boxes into lists of drawing commands, grouped into layers. Composite hands those layers to the compositor, which places and transforms them and produces the frame, often on a separate thread or the GPU.
The stages are ordered, so invalidating an earlier one drags the later ones with it. Changing width invalidates layout, therefore also paint and composite. Changing background-color skips layout but still requires paint and composite. Changing transform on an element that already has its own compositor layer skips both layout and paint.
Which properties land where
Properties that feed geometry force layout: width, height, padding, margin, border-width, top/left/right/bottom, display, position, float, font-size, font-family, line-height, text-align, and the flex and grid sizing properties.
Properties that only change pixels within a box already sized force paint but not layout: color, background-color, background-image, box-shadow, border-radius, outline-color, visibility.
transform and opacity are the two that can be changed without layout or paint in every major engine, and they are the two you should be animating. Chromium can also run filter animations on the compositor, but treat that as an engine-specific extension rather than something to rely on cross-browser. The qualifier matters: compositor-only holds when the element has been promoted to its own layer. Without promotion, a transform change still means repainting the layer the element shares with its neighbours. will-change: transform is the declarative way to request promotion, and it is a hint with a real cost — every promoted layer consumes memory and compositing work, so applying it to a list of five hundred rows makes things worse, not better.
Why the read is what hurts
Style and layout are normally deferred: you can set a dozen properties in one function and the browser coalesces them into a single layout before the next frame. That batching is what makes DOM writes cheap.
Reading a geometry property destroys it. offsetWidth, offsetHeight, offsetTop, clientWidth, scrollTop, scrollHeight, getBoundingClientRect() and getComputedStyle() for a layout-dependent value must all return a correct answer now. If there are pending style or layout invalidations, the browser has no choice but to flush the pipeline synchronously before returning. That is a forced synchronous layout, and it is entirely caused by the read, not by the write in front of it. A write on its own is queued; a read on its own is nearly free; a read after a write pays for a whole layout.
Put that pair inside a loop and every iteration invalidates layout and then immediately demands it back, so one pass over n elements costs n layouts instead of one. That is layout thrashing, and it scales with the size of the invalidated subtree as well as with n, which is why it tends to be invisible on a ten-row test fixture and catastrophic on a real page.
// Thrash: the read of offsetWidth on iteration k is forced to flush
// the layout that the write on iteration k-1 invalidated.
for (const el of boxes) {
el.style.width = el.offsetWidth + 10 + 'px';
}
// Batched: one layout for all the reads, then writes that stay queued
// until the browser renders the next frame.
const widths = boxes.map((el) => el.offsetWidth); // reads: one flush total
boxes.forEach((el, i) => { // writes: no reads between them
el.style.width = widths[i] + 10 + 'px';
});
The mistake that survives a code review
The fix that gets offered most often is "wrap it in requestAnimationFrame", and it does not work, because the scheduler was never the problem. A read-then-write pair inside a single rAF callback forces layout exactly as it did outside one. What fixes it is ordering: every read completes before the first write begins. requestAnimationFrame is useful in combination with that, because it puts the writes at the start of a frame where they will be flushed once, but on its own it just relocates the thrash.
The related half-answer is saying "transform is GPU-accelerated so it is free" without checking whether the element is on its own layer. When you need to be sure of either, record a performance profile and look at the frame: forced layouts are flagged in the trace, and the trace names the line of script that triggered them.
Writes are batched and reads are not. Any time a read of geometry sits after a write in the same synchronous block, you have bought a layout you did not need, and the fix is to reorder rather than to reschedule.
Likely follow-ups
- Why does a stylesheet that is still in flight delay execution of a classic script that appears after it?
- A designer wants an element's height animated from 0 to auto. What do you build instead, and what do you lose?
- How do `contain: layout` and `content-visibility: auto` change what a layout costs?
- A requestAnimationFrame callback calls getBoundingClientRect on two hundred elements and writes nothing at all. Is that layout thrashing?
Related questions
- A dashboard has been showing the same temperature for two days and nobody noticed. Walk the path and tell me what would have caught it.hardSame kind of round: scenario6 min
- Everyone in the business calls it the fax queue, and nothing has been faxed since 2014. Do you keep that name in the code?mediumSame kind of round: concept4 min
- How does your order placement change on a venue that allocates pro rata within a price level instead of first in, first out?hardSame kind of round: concept6 min
- Your A/B test came back not significant. What do you do next?hardSame kind of round: scenario4 min
- Leadership wants DORA metrics on a dashboard for every team. How do you use them well, and how would each one be gamed?hardSame kind of round: concept4 min
- A customer asks how you meet a control your cloud provider handles. What can you claim from their certification?mediumSame kind of round: concept5 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min
- Which delivery metrics would you actually track for a team, and why does velocity stop working the moment you set a target for it?mediumSame kind of round: concept6 min