Browser Rendering Pipeline
How a URL becomes pixels and stays 60fps: DNS/TCP/TLS/HTTP, DOM & CSSOM construction, the render tree, layout (reflow), paint, and GPU compositing — plus the cost ladder that tells you which changes are cheap and which trigger expensive reflows.
The big picture (read this first). A browser's only real job is to turn text files (HTML, CSS, JS) into colored pixels on your screen, and to keep those pixels updated as things change — ideally 60 times per second, i.e. a new frame every ~16.7ms. Everything below is just the step-by-step recipe it follows. Think of it like a kitchen: ingredients arrive (download), get prepped into structured bowls (DOM/CSSOM), combined into a dish plan (render tree), arranged on the plate (layout), colored/plated (paint), and finally the plates are stacked and carried out (composite). This whole path from bytes to first pixels is called the critical rendering path, and shortening it is what page-speed optimization is really about.
Step 1 — Get the files (network). When you type a URL: DNS translates the human name (google.com) into a numeric IP address (like looking up a contact's phone number). A TCP handshake (SYN / SYN-ACK / ACK) opens a reliable connection, and a TLS handshake layers encryption on top for https. An HTTP request then asks for the document, and the server streams back the HTML text. At this point the browser has just a wall of bytes — no boxes, no colors yet. Note the browser starts parsing as bytes arrive; it does not wait for the whole file.
Step 2 — Build the DOM (structure). The browser tokenizes the HTML and builds the DOM (Document Object Model) incrementally, top to bottom — a tree of objects, one node per tag. <body> is a node, the <h1> inside it is a child node, text becomes text nodes, and attributes hang off their element. The DOM is the browser's live, in-memory model of your page that JavaScript can later read and mutate. Because parsing is incremental, a slow <script> in the middle of the <head> can stall construction of everything below it.
Step 3 — Build the CSSOM (styling). In parallel the browser downloads and parses all CSS into the CSSOM (CSS Object Model) — a tree of which computed style rules apply to which elements, with the cascade already resolved. This is why CSS is called render-blocking: the browser refuses to paint anything until the CSSOM is complete, otherwise you'd see a flash of unstyled content (FOUC). Keep critical CSS small and inline it, and defer non-critical styles, to unblock the first paint sooner.
Step 4 — Scripts interrupt parsing (why defer/async matter). A plain <script> is parser-blocking: the browser stops building the DOM, downloads and executes the script, then resumes — and because scripts can read styles, it may also wait for pending CSS first. defer downloads the script in parallel and runs it after the DOM is fully parsed (order preserved) — the right default for app code. async runs it as soon as it downloads, in no guaranteed order — good for independent third-party tags. Put scripts at the end of <body> or mark them defer so they never block rendering.
Step 5 — Render tree (what's actually visible). The browser merges DOM + CSSOM into the render tree: only the nodes that will actually be drawn, each carrying its content and its computed styles. Elements with display: none are excluded entirely (they're in the DOM but produce no box). Contrast that with visibility: hidden, which is in the render tree — it takes up space and still costs layout, it's just not painted.
Step 6 — Layout / reflow (where & how big). Now the browser does the geometry math: for every render-tree node it computes exact position and size in the viewport — x, y, width, height — as a box. This is layout (also called reflow). Example: width: 50% only resolves to 640px here, once the browser knows the viewport is 1280px wide. Layout is expensive and cascades: changing one element's size can force recalculation of its ancestors, siblings, and descendants, potentially the whole subtree.
Step 7 — Paint (filling in pixels). The browser walks the render tree and produces a list of draw commands — 'fill this rectangle blue', 'draw this text here', 'round this corner', 'drop this shadow'. Painting rasterizes those commands into pixels on one or more layers (think transparent sheets stacked on top of each other). Purely visual changes — color, background, box-shadow, visibility — trigger a repaint without a reflow, which is cheaper because no geometry has to be recomputed.
Step 8 — Compositing (gluing layers together). Certain elements get promoted to their own layer (their own bitmap the GPU can move independently). The compositor stacks these layers in the correct order and hands the final image to the GPU for display. This is the secret behind smooth animation: moving or fading a layer with transform/opacity only re-composites — it skips layout and paint entirely — so it can run on the GPU/compositor thread at a steady 60fps even while the main thread is busy.
The cost ladder (the one thing to internalize). Not all changes are equal. From cheapest to most expensive: composite-only (transform, opacity) — no layout, no paint, GPU-driven; paint-only (color, background, visibility, box-shadow) — repaint but no reflow; layout/reflow (width, height, top/left, margin, padding, font-size, adding/removing DOM nodes) — recompute geometry, then repaint, then composite. That's why the golden rule of web animation is animate transform and opacity, never top/left/width.
Layout thrashing (the classic performance bug). The browser is smart: it batches DOM writes and flushes layout once before the next paint. But if you write a geometry property and then read a layout value (offsetHeight, offsetWidth, getBoundingClientRect(), scrollTop, getComputedStyle) in the same tick, the browser is forced to flush layout synchronously right now to give you an accurate answer. Do that inside a loop — read, write, read, write — and you trigger a forced synchronous reflow every iteration. The fix: batch all reads first, then all writes, and use requestAnimationFrame to schedule visual updates.
Then JavaScript keeps changing things. After first paint, JS mutates the DOM and the browser re-runs only the necessary part of the pipeline based on what changed: change geometry or structure → Layout + Paint + Composite (expensive); change only color → Paint + Composite (cheaper); change only transform/opacity → Composite only (cheapest). Understanding this decides whether your UI feels janky or buttery.
Why the Virtual DOM exists. The real DOM is not intrinsically 'slow' — a single property set is fast. What's slow is triggering many separate synchronous layout/paint cycles. React's Virtual DOM is a diffing layer: it computes the minimal set of real mutations in memory and applies them in one batched pass, so the browser reflows once instead of N times. It's an optimization for how often you touch the DOM, not a faster DOM.
The three languages, each with one job. HTML = structure and content (like your data model / schema). CSS = presentation and layout (a view / formatting layer). JavaScript = behaviour and interactivity (your business logic / controller layer). Keeping them separated is what keeps pages maintainable and lets each stage of the pipeline stay fast.
The mental model (memorise this). Bytes → DOM (structure) + CSSOM (style) → Render Tree (visible + styled) → Layout (geometry) → Paint (pixels) → Composite (layers to screen). CSS blocks rendering; plain scripts block parsing. After load, the browser re-runs only the stages your change dirtied — so prefer composite-only changes, batch your DOM reads and writes, and let the pipeline breathe.
Think of the DOM as a live, mutable tree in memory and the render pipeline as a continuous build → deploy loop that must finish inside a 16ms frame budget. A reflow is like a full rebuild-and-redeploy (recompile the world), a repaint is like a hot-swap of a single class, and a composite-only change is like flipping a feature flag — no rebuild at all. Layout thrashing is the equivalent of calling a blocking `.get()` on a future inside a tight loop in Vert.x: each read forces the event loop to stall and resolve work you should have batched. React's Virtual DOM plays the role you'd give a write-behind cache — accumulate changes and flush them to the expensive backing store (the DOM) in one transaction instead of committing after every field.
- Pipeline in order: bytes -> DOM + CSSOM -> render tree -> layout -> paint -> composite; the whole path is the critical rendering path.
- Cost ladder cheapest to most expensive: composite-only (transform/opacity) < paint-only (color, background, visibility) < layout/reflow (size, position, margin, DOM structure).
- Animate transform and opacity, never top/left/width - the former skip layout and paint and run on the GPU compositor at 60fps.
- Layout (reflow) cascades: changing one element's geometry can force recomputation of its ancestors, siblings, and descendants.
- Reading a layout value (offsetHeight, getBoundingClientRect, scrollTop) after writing one forces a synchronous reflow - this is layout thrashing. Batch all reads, then all writes.
- CSS is render-blocking (no paint until the CSSOM is ready); a plain script is parser-blocking - use defer for app code, async for independent third-party tags.
- display:none is excluded from the render tree (no box, zero cost); visibility:hidden stays in it (takes space, still costs layout, just isn't painted).
- The Virtual DOM batches and minimizes real DOM mutations so the browser reflows once instead of many times - it is not a faster DOM.
- A frame budget is ~16.7ms at 60fps; long main-thread work (heavy JS, forced reflows) blows the budget and causes jank.
- width:50% resolves to real pixels only during layout, once the browser knows the viewport size.
Worked Code
// BAD: interleaving reads and writes forces a synchronous reflow
// on EVERY iteration -> O(n) forced layouts.
for (const el of items) {
const w = el.offsetWidth; // READ (flushes pending layout NOW)
el.style.width = w + 10 + "px"; // WRITE (invalidates layout again)
}
// GOOD: batch all READs, then all WRITEs -> the browser lays out ONCE.
const widths = items.map((el) => el.offsetWidth); // all READs first
items.forEach((el, i) => {
el.style.width = widths[i] + 10 + "px"; // then all WRITEs
});
// BEST: schedule the visual write inside requestAnimationFrame so it
// lands right before the next paint, aligned to the frame budget.
const measured = items.map((el) => el.offsetWidth);
requestAnimationFrame(() => {
items.forEach((el, i) => (el.style.width = measured[i] + 10 + "px"));
});const box = document.querySelector(".box");
// EXPENSIVE: animating 'left' changes geometry -> Layout + Paint + Composite
// every frame. This is the classic janky animation.
// box.style.left = x + "px";
// CHEAP: 'transform' is composite-only -> the GPU moves the existing
// layer, skipping layout AND paint. Same visual result, 60fps.
box.style.transform = "translateX(" + x + "px)";
// Hint the browser to promote an element to its own layer BEFORE
// animating (add it, animate, then remove it to free GPU memory).
box.style.willChange = "transform";
// color-only change -> Paint + Composite (no reflow) = mid-cost.
box.style.color = "tomato";<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Inline the tiny bit of CSS needed for above-the-fold content so
the first paint isn't blocked waiting on a stylesheet download. -->
<style>body{font-family:system-ui;margin:0}.hero{min-height:60vh}</style>
<!-- Non-critical CSS: load without blocking render, then apply. -->
<link rel="preload" href="/styles.css" as="style"
onload="this.rel='stylesheet'" />
<!-- defer: download in parallel, run AFTER the DOM is parsed, in order.
The right default so scripts never block DOM construction. -->
<script src="/app.js" defer></script>
<!-- async: runs the moment it downloads, order NOT guaranteed.
Good for independent third-party tags (analytics, etc.). -->
<script src="https://cdn.example.com/analytics.js" async></script>
</head>
<body>
<div class="hero">Above the fold</div>
</body>
</html>// These reads all FLUSH pending layout so they can return an accurate
// value. Calling them right after a style write causes a forced reflow.
function forcesReflow(el: HTMLElement): void {
void el.offsetTop;
void el.offsetWidth;
void el.offsetHeight;
void el.getBoundingClientRect(); // position + size
void el.scrollTop; // scroll offsets
void window.getComputedStyle(el).height; // resolved computed style
}
// Rule: measure everything you need up front, cache it, THEN mutate.
// Never read a geometry property in the same loop where you write one.▶Try It Live
Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.
Interview-Ready Q&A
DNS resolves the domain to an IP, a TCP handshake plus a TLS handshake (for https) establishes a secure connection, and an HTTP request fetches the HTML. The browser parses HTML into the DOM incrementally and CSS into the CSSOM, combines the two into the render tree (visible nodes with their computed styles), runs layout to compute each box's geometry, paints pixels, and composites the layers to the screen. Plain scripts block parsing along the way; after load, JS mutations re-run whichever pipeline stages the change dirtied.
- 1Pipeline order: DNS -> TCP/TLS -> HTTP -> DOM + CSSOM -> Render Tree -> Layout -> Paint -> Composite.
- 2Layout = where/how big (geometry). Paint = filling pixels/colors. Composite = stacking layers onto the screen.
- 3Cost ladder: composite-only (transform/opacity) < paint-only (color/background) < layout/reflow (size/position/structure).
- 4Animate transform and opacity to stay on the cheapest, GPU-driven path; never animate top/left/width.
- 5CSS is render-blocking; a plain script is parser-blocking. Use defer for app code, async for third-party tags.
- 6Avoid layout thrashing: batch all DOM reads, then all writes; schedule visual updates with requestAnimationFrame.
- 7Reading offsetHeight/getBoundingClientRect/scrollTop after a write forces a synchronous reflow.
- 8display:none leaves the render tree (zero cost); visibility:hidden stays (still costs layout).
- 9The Virtual DOM batches mutations so the browser reflows once; it isn't a faster DOM.
- 10Frame budget is ~16.7ms at 60fps - long main-thread work causes jank.