How the Browser Renders a Web Page: Parsing, Layout, Paint, and Compositing from URL to Pixels
A deep dive into the browser's critical rendering path: HTML and CSS parsing, render tree assembly, layout computation, paint operations, and GPU compositing. With TypeScript examples for measuring and optimizing each phase.
Most frontend engineers understand the DOM. Fewer understand what happens between the browser receiving HTML bytes and those bytes becoming pixels on screen. That gap matters because the rendering pipeline is where layout thrashing, paint storms, and jank originate. If you can’t describe why offsetWidth forces a synchronous layout, or why transform is cheaper than top for animation, you’re debugging performance blind.
This article walks through the full critical rendering path: parsing, CSSOM construction, render tree assembly, layout, paint, and compositing. Each phase has its own failure modes. Understanding them turns “the page feels slow” into a specific, actionable diagnosis.
HTML Parsing and DOM Construction
When the browser receives an HTML response, the parser converts the byte stream into tokens and then builds the Document Object Model tree incrementally. This is not a two-phase process. The parser emits nodes as it reads, which is why DOMContentLoaded fires before images load and why you can observe partial DOM states in scripts.
The parser is not purely sequential. When it hits a <script> tag without async or defer, it blocks. The parser cannot continue until the script has fetched, parsed, and executed, because scripts can call document.write() and fundamentally alter the document structure. This is the original sin of render-blocking scripts. A 200ms network round trip for a synchronous third-party script creates a 200ms blank-screen window before any content appears.
Speculative parsing partially mitigates this. While the main parser is blocked on a script, a secondary preload scanner looks ahead in the HTML to discover other resources (images, stylesheets, scripts) and dispatches their fetches in parallel. The preload scanner cannot build the DOM, but it keeps the network busy.
// Measure how long HTML parsing takes using the Navigation Timing API
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === "navigation") {
const nav = entry as PerformanceNavigationTiming;
const domParsingMs = nav.domInteractive - nav.responseStart;
const domContentLoadedMs = nav.domContentLoadedEventEnd - nav.responseStart;
console.log(`DOM parsing: ${domParsingMs.toFixed(1)}ms`);
console.log(`DOMContentLoaded: ${domContentLoadedMs.toFixed(1)}ms`);
}
}
});
observer.observe({ type: "navigation", buffered: true });
The domInteractive timestamp is when the parser has finished and the DOM is available for scripting. domContentLoadedEventEnd is after DOMContentLoaded handlers have run. The delta between them tells you how expensive your DOMContentLoaded scripts are.
CSS Parsing and CSSOM Construction
While HTML parsing constructs the DOM, CSS parsing constructs a parallel tree: the CSS Object Model (CSSOM). Stylesheets are render-blocking by design. The browser will not render anything until it has computed styles for the entire document, because any element could be affected by any rule.
This render-blocking behavior is why inlining critical CSS matters. If the browser can compute styles for above-the-fold content from the initial HTML response without waiting for a stylesheet fetch, it can render the visible viewport sooner.
CSS parsing has its own complexity. Selectors are matched right-to-left: for .list .item span, the browser first finds all span elements, then checks if each has an ancestor .item, then checks if that ancestor has an ancestor .list. Deep selector chains in large documents create measurable parsing overhead. This is a practical reason to keep selector specificity shallow, independent of code style preferences.
The cascade, inheritance, and specificity resolution all happen during CSSOM construction. By the time the render tree is assembled, every DOM node that needs styling has a computed style with every property resolved to its final value.
Render Tree Assembly
The render tree combines the DOM and CSSOM. It contains only the nodes that will be rendered: elements with display: none are excluded entirely. visibility: hidden elements are included because they occupy space. Pseudo-elements like ::before and ::after are included even though they have no DOM nodes.
Each render tree node corresponds to a layout box. Block-level elements produce block boxes. Inline elements produce inline boxes. The box model is computed at this stage: content size, padding, border, and margin values are resolved against their containing blocks.
Understanding the render tree is what makes display: none versus visibility: hidden a meaningful performance distinction. Toggling display: none removes and re-inserts a subtree from the render tree, triggering layout. Toggling visibility: hidden keeps the subtree in place, which is cheaper if the element’s dimensions are stable.
Layout: Geometry Computation
Layout (also called reflow) computes the exact position and size of every render tree node. This is where percentages resolve to pixels, where floats interact, where flexbox and grid algorithms run.
Layout is recursive and expensive. The document has a single layout root. A change to an element’s size can affect its siblings, its parent, and potentially the entire document. Browsers optimize by tracking “dirty” nodes, but a change anywhere in the flow can invalidate a large subtree.
The most common performance mistake is forced synchronous layout, also called layout thrashing. It happens when you read a layout property (like offsetWidth, scrollTop, getBoundingClientRect) after a style mutation in the same frame. The browser cannot return a stale value, so it flushes pending style changes and runs layout synchronously before continuing.
// This pattern causes layout thrashing.
// Each iteration forces a synchronous layout.
const elements = document.querySelectorAll(".card");
elements.forEach((el) => {
const width = el.offsetWidth; // forces layout
(el as HTMLElement).style.width = `${width * 0.9}px`; // mutates style
});
// Correct approach: batch reads before writes using requestAnimationFrame
function updateCardWidths(elements: NodeListOf<Element>): void {
// Read phase: collect all measurements
const widths = Array.from(elements).map(
(el) => (el as HTMLElement).offsetWidth
);
// Write phase: apply all mutations in one batch
requestAnimationFrame(() => {
elements.forEach((el, i) => {
(el as HTMLElement).style.width = `${widths[i] * 0.9}px`;
});
});
}
The requestAnimationFrame callback runs at the start of a new frame, after the browser has finished processing the previous frame’s layout. Reads collected before the rAF callback see the previous frame’s geometry. Mutations inside the callback are batched into the next paint cycle.
The CSS contain property lets you declare layout containment to the browser explicitly:
.widget {
contain: layout;
}
With contain: layout, the browser knows that nothing inside .widget can affect layout outside it, and nothing outside can affect layout inside. This converts a document-wide layout operation into a subtree-scoped one. The contain: strict shorthand enables layout, style, and paint containment together, which is appropriate for self-contained components like cards, widgets, or virtualized list items.
Paint: Drawing Operations
After layout, the browser generates paint records for each render tree node. Paint does not produce pixels directly. It produces a display list: a sequence of drawing commands (“fill this rectangle with this color”, “draw this text at these coordinates with this font”). The display list is then executed, either on the CPU or handed to the GPU for rasterization.
Paint is organized into layers. The browser uses a stacking context model to determine which elements form their own layers. Elements with position: fixed, opacity < 1, transform, filter, or will-change: transform create new compositor layers. Each layer is rasterized independently and cached as a texture on the GPU.
This layering is the reason transform and opacity animations are cheap while width, height, top, and left animations are expensive. Moving an element with transform: translateX() does not require re-layout or re-paint. The compositor takes the cached GPU texture and repositions it, bypassing the main thread entirely. Animating left changes geometry, which forces layout, which forces repaint, which rerasterizes the texture, all synchronously on the main thread.
// Instrument paint events using the Long Animation Frame API
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`Long frame detected: ${entry.duration.toFixed(1)}ms`);
// entry.scripts gives you the scripts that ran in this frame (Chrome 123+)
}
}
});
observer.observe({ type: "long-animation-frame", buffered: true });
Long Animation Frame (LoAF) entries, available in Chrome 123 and later, capture frames where the browser took more than 50ms to process. A frame budget at 60fps is 16.7ms. At 120fps it’s 8.3ms. Anything above 50ms is visible jank.
The will-change property is a hint to the browser that an element’s visual properties are about to change:
.animated-card {
will-change: transform, opacity;
}
The browser can pre-promote the element to its own compositor layer before the animation starts, avoiding a mid-animation promotion that itself causes a repaint. The downside is memory. Every compositor layer occupies GPU memory. Applying will-change to every element is worse than applying it to none. Use it on specific elements that animate, and remove it after the animation completes.
function animateCard(card: HTMLElement): void {
card.style.willChange = "transform, opacity";
card.animate(
[
{ transform: "translateY(0)", opacity: "1" },
{ transform: "translateY(-8px)", opacity: "0.85" },
{ transform: "translateY(0)", opacity: "1" },
],
{ duration: 300, easing: "ease-out" }
).onfinish = () => {
card.style.willChange = "auto";
};
}
Compositing: GPU Assembly
Compositing is the final phase. The compositor thread takes the rasterized layer textures produced by the paint phase and assembles them into the final frame that appears on screen. Critically, the compositor thread runs independently of the main thread. It can produce frames even when the main thread is busy running JavaScript.
This independence is the fundamental reason why CSS animations on transform and opacity do not jank even under main thread load. The compositor does not need to wait for JavaScript to finish. It just repositions textures.
Scroll is the clearest example. Modern browsers scroll using the compositor thread. The browser rasterizes tiles around the viewport and the compositor repositions them as the user scrolls. If JavaScript is blocking the main thread, scrolling still works because the compositor has what it needs. This changes when scroll event handlers trigger style mutations: now the compositor has to wait for the main thread to respond before it can determine where to paint, which produces the “sticky” scroll feel that plagued pre-passive-listener codebases.
// Use passive event listeners so the compositor doesn't wait for your handler
window.addEventListener(
"scroll",
() => {
// Reading scroll position is fine.
// Mutating styles here will force compositor-main thread synchronization.
const y = window.scrollY;
updateProgressBar(y); // reads only, no style mutations
},
{ passive: true }
);
The passive: true option tells the browser that the scroll handler will never call preventDefault(). The compositor can then scroll immediately without waiting for the handler to finish.
Rendering Optimization Strategies: Tradeoffs
| Strategy | What It Fixes | Cost | When to Use |
|---|---|---|---|
contain: layout | Limits layout scope to a subtree | Breaks percentage-based sizing that depends on parent | Self-contained components with fixed or content-driven dimensions |
contain: strict | Limits layout, style, and paint scope | Element must not overflow its bounds visually | Virtualized list items, widgets, embedded components |
will-change: transform | Pre-promotes element to GPU layer | GPU memory per promoted layer; compounds with many elements | Elements with known-upcoming animations; remove after animation ends |
CSS transform instead of top/left | Removes layout from animation critical path | No functional layout change; requires understanding of stacking contexts | All animations that move elements |
requestAnimationFrame batching | Eliminates layout thrashing | Adds one frame of latency for reads-then-writes patterns | Any loop that alternates DOM reads and writes |
IntersectionObserver for lazy loading | Removes off-screen resource fetches | Observer setup overhead; requires threshold tuning | Images, iframes, and heavy components below the fold |
| Passive scroll listeners | Allows compositor to scroll without main thread sync | Cannot call preventDefault() in the handler | All scroll listeners that don’t need to block scroll |
| Content-visibility: auto | Skips layout and paint for off-screen sections | Browser estimates size, causing layout shift when sections enter viewport without explicit contain-intrinsic-size | Long document sections (blog posts, lists) with predictable height |
Production Considerations: Core Web Vitals
Core Web Vitals are Google’s operationalized version of the rendering pipeline’s failure modes. Each one maps to a specific stage.
Largest Contentful Paint (LCP) measures when the largest above-the-fold element (image or text block) finishes rendering. The critical path here is: HTML parsing, resource fetch, decode, layout, and paint. Slow LCP almost always traces to one of: render-blocking stylesheets delaying DOM processing, a large image without preload hints, or server response latency. The fix is rarely “optimize the image” in isolation; it usually requires tracing the full waterfall.
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lcp = entries[entries.length - 1] as PerformancePaintTiming & {
element?: Element;
loadTime?: number;
renderTime?: number;
url?: string;
};
console.log(`LCP element: ${lcp.element?.tagName}`);
console.log(`LCP time: ${(lcp.renderTime || lcp.loadTime || 0).toFixed(1)}ms`);
});
observer.observe({ type: "largest-contentful-paint", buffered: true });
Cumulative Layout Shift (CLS) measures unexpected layout shifts after content has rendered. The common causes are images without explicit width and height attributes (browser cannot reserve space before the image loads), late-injected content above existing content (ad slots, consent banners), and web fonts that cause a layout shift when they swap in. The fix is always to reserve space before content arrives, never to defer or hide the shift.
let cumulativeCLS = 0;
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
hadRecentInput: boolean;
sources?: Array<{ node?: Node }>;
};
if (!shift.hadRecentInput) {
cumulativeCLS += shift.value;
console.log(`Layout shift: ${shift.value.toFixed(4)}, cumulative: ${cumulativeCLS.toFixed(4)}`);
// shift.sources[0].node tells you which element shifted
}
}
});
clsObserver.observe({ type: "layout-shift", buffered: true });
Interaction to Next Paint (INP) replaced First Input Delay as of March 2024. INP measures the latency of all interactions (clicks, keypresses, taps) across the page lifecycle, then takes the worst percentile. Long INP traces to: JavaScript blocking the main thread during the input event handler, or expensive layout and paint triggered by the interaction. The fix is to keep event handlers short and defer non-critical work with setTimeout(fn, 0) or scheduler.postTask.
const inpObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const interaction = entry as PerformanceEventTiming;
const duration = interaction.duration;
if (duration > 200) {
// INP threshold: good < 200ms, needs improvement < 500ms, poor >= 500ms
console.warn(
`Slow interaction: ${interaction.name}, duration: ${duration.toFixed(1)}ms`,
`processing: ${(interaction.processingEnd - interaction.processingStart).toFixed(1)}ms`
);
}
}
});
inpObserver.observe({ type: "event", durationThreshold: 16, buffered: true });
The processingEnd - processingStart delta isolates JavaScript execution time from input delay and presentation delay. If processing is fast but total duration is high, the bottleneck is the paint phase after the handler runs, usually because the handler triggered an expensive layout.
Closing
The browser rendering pipeline is not a black box. Every stage has defined inputs, outputs, and failure modes. HTML parsing blocks on synchronous scripts. CSS parsing blocks rendering entirely. Layout thrashing happens when reads and writes interleave. Paint is expensive when it covers large areas or runs on the main thread. Compositing is where animations live or die.
Debugging performance without this mental model means guessing. With it, a CLS report points you directly to elements without reserved dimensions. A long INP points to layout triggered inside an event handler. A slow LCP points to the resource waterfall, not the image itself.
Read the pipeline. Then read the PerformanceObserver data. The answer is usually visible.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.