How the browser works
The critical rendering path, reflow vs repaint (and why transform/opacity skip both), and why CSS is render-blocking while scripts are parser-blocking.
Between the bytes arriving over the network and a pixel lighting up on screen, the browser runs a fixed pipeline. Knowing the stages — and which user actions or CSS properties re-trigger which stages — is the whole game for frontend performance. Almost every “why is this page janky?” answer reduces to which stage of this pipeline am I accidentally re-running, and how often?
The critical rendering path
The browser turns markup and styles into pixels in a strict order. Two independent parse streams (HTML and CSS) converge into the render tree, which then flows through layout, paint, and composite:
- HTML → DOM. The parser reads HTML top-to-bottom and builds the node tree incrementally.
- CSS → CSSOM. Every stylesheet (and inline style) is parsed into the style tree. The cascade is resolved here.
- Render tree. The DOM and CSSOM are combined. Invisible nodes (
head,display:noneelements) are dropped; everything visible gets its computed style attached. - Layout / reflow. The browser walks the render tree and calculates the box geometry — where each element sits and how big it is — in actual pixels.
- Paint. Each box is rasterized into pixels on one or more layers.
- Composite. Layers are drawn to the screen in the correct stacking order, often GPU-accelerated.
Anything that changes the page after first paint re-enters this pipeline at some stage — and how far up it re-enters is what determines cost.
Reflow vs repaint vs composite
The single most useful performance idea in the browser: changing a property forces the pipeline to re-run from a certain stage onward. Triggering only the last stage is cheap; triggering layout is expensive because it can cascade to siblings, parents, and children.
| You change… | Re-runs from | Cost | Examples |
|---|---|---|---|
| Geometry (size/position) | Layout (reflow) | Highest — can cascade across the page | width, height, top, margin, font-size, adding/removing a node |
| Visual-only properties | Paint (repaint) | Medium — re-rasterize, no geometry | color, background, box-shadow, visibility |
| Compositor-friendly props | Composite only | Lowest — GPU re-blends existing layers | transform, opacity |
This is why the standard advice for animations is to animate transform and opacity, not top/left/width. A transform-based slide can skip layout and paint entirely — the browser just re-composites a layer it already has — so it can hit 60fps where an equivalent left animation reflows on every frame.
Both snippets move a box 200px to the right. The first reflows the whole page on every frame; the second only re-composites a layer the GPU already holds.
/* SLOW — animating `left` forces a reflow each frame */
.box {
position: relative;
animation: slide-bad 1s infinite;
}
@keyframes slide-bad {
to { left: 200px; } /* geometry change → layout → paint → composite */
}
/* FAST — `transform` is handled by the compositor */
.box {
animation: slide-good 1s infinite;
}
@keyframes slide-good {
to { transform: translateX(200px); } /* composite only */
}The visual result is identical; the frame cost is not. transform/opacity are the only two “safe” properties to animate at high frequency.
Render-blocking CSS and parser-blocking scripts
Two resources can stall the pipeline, and they stall it in different ways.
CSS is render-blocking. The browser will not paint until it has the CSSOM, because painting with incomplete styles would cause a flash of unstyled content. So a large or slow stylesheet in <head> delays first paint of the entire page. Keep critical CSS small and defer or split non-critical styles.
Scripts are parser-blocking. A plain <script src> stops HTML parsing dead: the browser must fetch, then execute the script before continuing — because the script might call document.write or read/modify the DOM built so far. Worse, script execution also waits on any pending CSSOM (a script might query computed styles), so CSS can block JS too.
async and defer exist to unblock the parser. Both let the HTML keep parsing while the script downloads in parallel; they differ in when the script runs.
| Attribute | Download | Executes | Order preserved? | Use for |
|---|---|---|---|---|
| (none) | blocks parser | immediately, parser paused | yes | rare — only when you truly need synchronous DOM access |
async | parallel, non-blocking | as soon as it arrives (may interrupt parsing) | no — whoever lands first | independent third-party scripts (analytics) |
defer | parallel, non-blocking | after HTML is fully parsed, before DOMContentLoaded | yes — in document order | your app code that touches the DOM |
<!-- Blocks the parser until it has finished executing -->
<script src="blocking.js"></script>
<!-- Downloads in parallel; runs the instant it lands, order not guaranteed -->
<script src="analytics.js" async></script>
<!-- Downloads in parallel; runs after parse, in order — the default for app code -->
<script src="app.js" defer></script>Rule of thumb: reach for defer for anything that manipulates the DOM (it guarantees the DOM is ready and preserves order), and async only for fire-and-forget scripts with no dependencies. Modern <script type="module"> is deferred by default.
Why batching matters: the layout queue
Browsers are smart about layout. When you write a dozen style changes in a row, the browser does not reflow a dozen times — it queues the changes and flushes them in one reflow at the end of the task (“coalescing”). The disaster happens when your code forces a flush in the middle by reading a value that depends on up-to-date geometry. Properties that force a synchronous reflow include offsetTop/offsetHeight/offsetWidth, clientHeight, scrollTop, getBoundingClientRect(), and getComputedStyle() of a layout property. Reading any of these says to the browser: “I need an accurate number now, so flush every pending change first.” Interleave reads and writes and you defeat the coalescing entirely — that is layout thrashing.
01 Learning objectives
0 / 3 done02 Curated reading
03 Knowledge check
- 01easy
Changing an element's geometry (size/position) triggers a:
- 02medium
Which property pair lets the compositor skip BOTH layout and paint?
- 03medium
`defer` scripts execute after HTML parsing, in document order.
04 Interview questions
browse all ↗What gets asked on this topic — tap a card for how to approach it, the follow-ups, and the trap. Company tags are best-effort & sourced.
-
Walk me through what happens from typing a URL to seeing the page render.
DNS resolves the host, TCP+TLS connect, the browser requests the HTML and parses it into the DOM; CSS is parsed into the CSSOM; DOM + CSSOM combine into the render tree. Then layout (reflow) computes geometry, paint fills pixels, and composite assembles layers on the GPU.
Note that CSS is render-blocking and
<script>is parser-blocking unless markedasyncordefer. This whole sequence is the critical rendering path.Follow-ups they push on- Why can transform/opacity animations skip layout and paint?
- Where does the JS engine block the parser, and how do async/defer change that?
Red flag Forgetting the CSSOM, or conflating reflow (layout) with repaint (paint). Saying the DOM alone produces pixels.
source: web.dev — Critical rendering path ↗ -
What is the difference between reflow and repaint?
Reflow (layout) recomputes element geometry — sizes and positions. It is expensive because changing one element can cascade to its ancestors, descendants, and siblings. Triggers: width/height, margin/padding, font-size, adding/removing DOM nodes, reading
offsetHeight.Repaint redraws pixels without changing geometry — e.g.
color,background-color,visibility. Cheaper than reflow.Composite-only changes (
transform,opacity) can skip both layout and paint and run on the GPU's compositor thread, which is why they animate smoothly.Follow-ups they push on- Why does reading offsetWidth in a loop after writing styles cause layout thrashing?
- How would you batch DOM reads and writes to avoid forced synchronous layout?
Red flag Claiming color changes cause reflow, or that all CSS animations are cheap. Animating `top`/`left`/`width` triggers reflow every frame; `transform` does not.
source: web.dev — Critical rendering path ↗ -
Why is CSS render-blocking, and why is a plain <script> parser-blocking?
CSS is render-blocking because the browser will not paint until it has the CSSOM — rendering with incomplete styles would cause a flash of unstyled content. So it blocks the first render, though not DOM construction.
A plain
<script>is parser-blocking: when the parser hits it, it stops building the DOM, fetches (if external) and executes the script, then resumes. Scripts can read and mutate the DOM, so the browser cannot safely keep parsing past them. This is why scripts are traditionally placed at the end of<body>.Follow-ups they push on- What do async and defer change about this?
- What is a render-blocking resource vs a parser-blocking one?
Red flag Saying CSS blocks DOM construction (it blocks render, not the DOM), or that all scripts block the parser regardless of attributes.
source: web.dev — Critical rendering path ↗ -
What is the difference between async and defer on a script tag?
Both download the script in parallel without blocking the parser; they differ in when execution happens and whether order is preserved.
defer: execute after the HTML is fully parsed, just beforeDOMContentLoaded, and in document order. Good for scripts that depend on the DOM or on each other.async: execute as soon as the download finishes, which can interrupt parsing, and in no guaranteed order. Good for independent scripts like analytics.A plain script (no attribute) blocks the parser while it downloads and runs.
Follow-ups they push on- Which would you use for a third-party analytics snippet, and which for an app bundle?
- Do async/defer affect inline scripts?
Red flag Swapping the two, or claiming async preserves order. async is order-independent; defer preserves order. (async/defer are ignored on inline scripts.)
source: MDN — <script>: async and defer ↗ -
Why do animating transform and opacity perform better than animating top/left or width/height?
top/left/width/heightchange geometry, so every animation frame triggers layout (reflow), then paint, then composite — on the main thread.transformandopacitycan be handled by the compositor: the element is promoted to its own layer and the GPU moves/blends it without re-running layout or paint. The work happens off the main thread, so it stays smooth even if JS is busy.Practical rule: animate
transformandopacity; usewill-changesparingly to hint layer promotion.Follow-ups they push on- What is the downside of promoting too many layers with will-change?
- What is the compositor thread and how is it separate from the main thread?
Red flag Overusing `will-change` on everything (memory blow-up, no benefit), or believing all CSS animations bypass the main thread.
source: web.dev — Animations and performance ↗ -
What is layout thrashing, and how do you avoid forced synchronous layout?
Layout thrashing is repeatedly interleaving DOM writes and layout-forcing reads in a loop, so the browser must recompute layout synchronously over and over.
Reading a property like
offsetHeight,getBoundingClientRect(), orscrollTopafter a style write forces the browser to flush pending layout immediately so the read is accurate — a forced synchronous layout.Fix: batch all reads first, then all writes. Libraries like FastDOM do this;
requestAnimationFramecan schedule the write phase.Follow-ups they push on- Which DOM properties force a synchronous layout when read?
- How does requestAnimationFrame help schedule reads vs writes?
Red flag Reading offsetWidth and then writing style in the same loop iteration, forcing a reflow each pass.
source: web.dev — Avoid large, complex layouts and layout thrashing ↗ -
What is the difference between the DOMContentLoaded and load events?
DOMContentLoadedfires when the HTML is fully parsed and the DOM is built — deferred scripts have run, but it does not wait for stylesheets, images, or subframes.loadfires later, when the page and all dependent resources (images, stylesheets, iframes) have finished loading.Most app initialization that only needs the DOM should run on
DOMContentLoaded(or just usedefer); reserveloadfor logic that needs final layout or image dimensions.Follow-ups they push on- Does DOMContentLoaded wait for async scripts?
- When would you actually need the load event?
Red flag Thinking DOMContentLoaded waits for images, or putting all init in load and delaying interactivity unnecessarily.
source: MDN — Document: DOMContentLoaded event ↗ -
What is the critical rendering path and how would you optimize it?
The critical rendering path is the sequence of steps the browser takes to turn HTML, CSS, and JS into pixels: build the DOM, build the CSSOM, combine into the render tree, lay out, paint, composite.
Optimizing it means getting the first meaningful paint sooner by reducing critical resources:
- Inline critical CSS, defer the rest; minimize render-blocking CSS.
- Adddefer/asyncto scripts so they do not block parsing.
- Preload key assets (<link rel="preload">), preconnect to origins.
- Minify and compress; reduce bytes and round-trips.Follow-ups they push on- How does inlining critical CSS help LCP?
- What is the tradeoff of inlining vs caching a separate CSS file?
Red flag Listing micro-optimizations without naming the blocking resources (CSS render-blocking, scripts parser-blocking) that actually delay first paint.
source: web.dev — Critical rendering path ↗ -
How does the browser build the DOM and the CSSOM, and how do they combine into the render tree?
The browser tokenizes the HTML bytes into nodes and assembles them into the DOM tree — a complete model of the markup. In parallel it parses CSS (inline,
<style>, and external) into the CSSOM, a tree of style rules with the cascade resolved.The render tree combines the two: it walks the DOM and attaches computed styles, but includes only the nodes that will be painted. Nodes with
display:noneare excluded entirely;<head>and<script>are not visual so they are absent too.visibility:hiddennodes stay in the tree (they occupy space).The render tree then feeds layout, which computes each node's geometry.
What a strong answer coversDOM = full parsed markup; CSSOM = parsed style rules with the cascade applied.
The render tree = DOM nodes that will be displayed, each annotated with computed styles.
display:nonenodes are excluded from the render tree;visibility:hiddennodes are kept (they still take space).The CSSOM cannot be built incrementally the way the DOM can — CSS is treated as render-blocking until fully parsed.
Quick self-checkWhich node is present in the DOM but NOT in the render tree?
-
Still in the render tree — it occupies layout space, just isn't painted.
-
Correct — display:none nodes are excluded from the render tree entirely.
-
Still rendered and laid out; it's just fully transparent.
-
A normal visible element — present in the render tree.
Follow-ups they push on- Why is the render tree not a 1:1 copy of the DOM?
- Why does an element with display:none not appear in the render tree but visibility:hidden does?
Red flag Saying the render tree is just the DOM, or that display:none and visibility:hidden are treated the same here. display:none drops the node entirely; visibility:hidden keeps it (with its box).
source: web.dev — Constructing the Object Model (CRP) ↗ -
Why can the browser parse HTML and discover sub-resources before the document is fully loaded? What is the preload scanner?
Modern browsers run a secondary preload scanner (also called a lookahead pre-parser) that races ahead of the main HTML parser. While the main parser may be blocked executing a synchronous
<script>, the preload scanner scans the raw markup for resources —<img>,<link>,<script src>— and starts fetching them early.This is why a render-blocking script does not also stall *network* discovery of later assets. It is also why CSS injected by JavaScript (rather than declared in markup) can hurt performance: the preload scanner cannot see it, so the fetch starts late.
Takeaway: keep critical resources in the initial HTML as plain
<link>/<img>so the scanner can find them.What a strong answer coversThe preload scanner pre-parses raw HTML to discover and fetch sub-resources ahead of the main parser.
It keeps the network busy even when the main parser is blocked on a synchronous script.
It only sees resources declared in the markup — JS-injected assets are invisible to it.
Declaring critical assets as plain tags (or
<link rel=preload>) lets discovery start as early as possible.
Follow-ups they push on- Why might lazy-loading or injecting your LCP image via JS hurt LCP?
- How does <link rel=preload> interact with the preload scanner?
Red flag Assuming a blocking script also blocks all network discovery — the preload scanner keeps fetching declared resources. Hiding critical assets behind JS injection defeats it.
source: web.dev — How the browser's preload scanner speeds up page loads ↗ -
What is the difference between a render-blocking resource and a parser-blocking resource?
Render-blocking resources prevent the browser from painting the first frame until they are processed — chiefly CSS (and synchronous CSS in
<head>). The DOM may keep being built, but nothing is shown until the CSSOM is ready.Parser-blocking resources halt DOM construction itself. A synchronous
<script>is the classic case: the parser stops, fetches and runs the script, then resumes — because the script coulddocument.writeor mutate the not-yet-built DOM.They overlap (a blocking script is effectively both, since stopping the parser also delays render), but the mental model differs: CSS blocks *painting*, scripts block *parsing*.
What a strong answer coversRender-blocking (CSS): DOM keeps building, but first paint waits for the CSSOM.
Parser-blocking (sync
<script>): DOM construction itself pauses until the script runs.async/defermake scripts non-parser-blocking;mediaqueries /printcan make a stylesheet non-render-blocking.A synchronous in-
<head>script behind a stylesheet is doubly bad: it waits for the CSS, then blocks the parser.
Follow-ups they push on- Why might a synchronous script wait for a preceding stylesheet to load?
- How do you make a stylesheet non-render-blocking with the media attribute?
Red flag Conflating the two: CSS blocks render (not DOM construction); a plain script blocks parsing (and therefore render too).
source: web.dev — Render blocking resources ↗ -
What is the compositor thread, and how is the browser's main thread different from it?
The main thread runs JavaScript, parses HTML/CSS, computes style, layout, and paint. If it is busy (a long task), the page cannot respond to input or update the DOM — this is what hurts INP.
The compositor thread runs separately and assembles already-painted layers into the final frame, handling scrolling and
transform/opacityanimations on the GPU. Because it does not need the main thread, scrolling and compositor-driven animations stay smooth even while JS is busy — until they need a property that forces layout/paint, which bounces work back to the main thread.This split is why
transform/opacityanimate at 60fps and why heavy JS tanks responsiveness but not necessarily scroll.What a strong answer coversMain thread: JS execution, style, layout, paint — a single thread that blocks the whole page when busy.
Compositor thread: stitches painted layers, handles scroll and
transform/opacityoff the main thread (often GPU-accelerated).Compositor-only changes (
transform,opacity) skip layout and paint, so they animate even during main-thread work.Long main-thread tasks block input handling and DOM updates, degrading responsiveness (INP).
Follow-ups they push on- Why does animating `top`/`left` re-involve the main thread every frame?
- How does breaking up long tasks improve responsiveness?
Red flag Believing all animations run off the main thread, or that the compositor can recompute layout. It only composites already-painted layers.
source: web.dev — Inside look at modern web browser (the compositor) ↗ -
What does this code do to rendering performance, and how would you fix it? for (const el of items) { el.style.width = el.offsetWidth + 10 + 'px'; }
Each iteration writes a style (
el.style.width = ...) and then the next read ofoffsetWidthforces the browser to flush layout so the read is accurate — a forced synchronous layout on every pass. With N items you get N reflows: classic layout thrashing.Fix: split into a read phase then a write phase so layout is computed at most once.
const widths = items.map((el) => el.offsetWidth);items.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px'; });Now all reads happen against one stable layout, and all writes are batched before the next reflow.
What a strong answer coversReading
offsetWidthafter a style write forces a synchronous layout so the value is fresh.Interleaving read/write per iteration = one reflow per item = layout thrashing.
Fix: batch all reads first, then all writes (read/write separation).
requestAnimationFramecan schedule the write phase to align with the next frame.
Quick self-checkWhy is the original loop slow?
-
Correct — the write invalidates layout and the next read flushes it, N times.
-
It dirties layout, but the slowness is the forced read-after-write, not paint.
-
Loop form is irrelevant; the layout thrashing dominates.
-
offsetWidth returns a number; parsing isn't the bottleneck.
Follow-ups they push on- Which properties besides offsetWidth force a synchronous layout when read?
- How would FastDOM or requestAnimationFrame help here?
Red flag Thinking the cost is the loop itself rather than the read-after-write pattern that forces a reflow each iteration.
source: web.dev — Avoid large, complex layouts and layout thrashing ↗ -
What is a layer (compositor layer), and what is the tradeoff of promoting elements with will-change?
The browser can split the page into compositor layers — separate bitmaps the GPU can transform and blend independently. Promoting an element to its own layer lets the compositor move it (via
transform) without repainting, which is what makes such animations cheap.will-change: transform(oropacity) hints the browser to promote an element ahead of time so the first frame is not janky. The tradeoff: each layer costs GPU memory, and too many layers add management overhead that can make things slower, not faster.Rule of thumb: apply
will-changejust before an animation and remove it after; never blanket it onto many elements.What a strong answer coversA compositor layer is an independently rasterized surface the GPU can move/blend without repaint.
will-changeproactively promotes an element so animations start smoothly.Each layer consumes GPU memory; over-promotion causes overhead and can regress performance.
Apply
will-changenarrowly and temporarily, not as a global optimization.
Follow-ups they push on- How can you inspect layers in DevTools (the Layers panel)?
- Why is `will-change: transform` on every element a bad idea?
Red flag Treating `will-change` as a free speed-up and applying it everywhere — it inflates memory and can hurt performance.
source: MDN — will-change ↗ -
How does a browser repaint at 60fps, and what is the ~16ms frame budget? Where does requestAnimationFrame fit?
At a 60Hz refresh rate the browser aims to produce a new frame every ~16.7ms (1000/60). Within that budget it must run any JS, recalculate style, lay out, paint, and composite — so a long-running task that overruns 16ms causes a dropped frame (jank).
requestAnimationFrame(cb)schedulescbto run right before the next paint, so visual updates align with the frame instead of firing at arbitrary times (assetTimeoutwould). It is the correct place to do animation work and DOM writes that should be visible next frame.Real budget is less than 16ms because the browser itself needs some of it; aim to keep main-thread work well under that.
What a strong answer covers60fps means a frame roughly every 16.7ms (1000ms / 60).
All per-frame work (JS, style, layout, paint, composite) must fit the budget or a frame drops.
requestAnimationFrameruns callbacks just before the next repaint, syncing visual updates to the frame.Prefer rAF over setTimeout for animation; setTimeout isn't aligned to the refresh cycle.
Follow-ups they push on- Why is requestAnimationFrame better than setTimeout for animations?
- What happens to rAF callbacks in a background (hidden) tab?
Red flag Using setTimeout for smooth animation (not frame-aligned), or assuming you have the full 16ms — the browser's own work eats into it.
source: MDN — Window: requestAnimationFrame() ↗ -
How do async, defer, and type="module" scripts differ in download and execution timing?
A plain
<script>blocks the parser: download and run happen inline, halting DOM construction.async: downloads in parallel; runs as soon as it arrives, possibly interrupting parsing, in no guaranteed order.defer: downloads in parallel; runs after the document is parsed, just beforeDOMContentLoaded, in document order.type="module"scripts are deferred by default (no attribute needed) and execute in order; addingasyncto a module makes it run as soon as it and its imports are ready. Modules are also always strict mode and have their own scope.Quick rule: app/UI code →
defer(or a module); independent third-party (analytics) →async.What a strong answer coversPlain script: parser-blocking download + execute.
async: parallel download, run on arrival, unordered.defer: parallel download, run after parse in order (before DOMContentLoaded).type="module": deferred by default, ordered, strict mode, scoped.
Quick self-checkBy default (no async/defer attribute), when does a <script type="module"> execute?
-
Modules are deferred by default; they don't block parsing.
-
Correct — module scripts behave like deferred scripts by default.
-
That's async behavior; modules default to defer, not async.
-
Deferred scripts run before DOMContentLoaded, well ahead of load.
Follow-ups they push on- Why is a module script deferred even without the defer attribute?
- What ordering guarantees do you lose with async?
Red flag Adding `defer` to a module thinking it's required (it's already deferred), or assuming async preserves execution order.
source: MDN — <script> type=module / async / defer ↗