Browser Rendering Pipeline
2026-08-05 · 18 min read
Understanding the browser rendering pipeline is the foundation for every performance optimization decision. When you add defer to a script, inline critical CSS, or animate with transform instead of left, you're exploiting specific properties of this pipeline. This post walks through every stage — from the first network byte to the final composited frame.
Navigation
Before any parsing happens, the browser has to get the HTML. This involves more steps than most engineers realize.
DNS resolution
The browser needs to turn a hostname into an IP address. It checks in order:
- Browser DNS cache
- OS DNS cache (
/etc/hosts, system resolver cache) - Recursive resolver (your ISP or a public resolver like 8.8.8.8)
- Root nameserver → TLD nameserver (
.com,.io) → authoritative nameserver
A cold DNS lookup adds 20–120ms depending on geography. <link rel="dns-prefetch"> kicks off this lookup early for third-party origins.
TCP handshake
Once the IP is known, the browser opens a TCP connection with a three-way handshake:
Client → SYN → Server
Client ← SYN-ACK ← Server
Client → ACK → Server
One round-trip before any data flows. On a 50ms RTT connection, that's 50ms just to establish the connection.
TLS handshake
HTTPS adds a TLS handshake on top of TCP. TLS 1.3 (the current standard) does this in one round-trip after TCP. TLS 1.2 requires two. The handshake negotiates a cipher suite and exchanges keys before the first HTTP byte can be sent.
<link rel="preconnect"> performs DNS + TCP + TLS early for origins the page will need, eliminating this cost from the critical path.
HTTP request and TTFB
Once connected, the browser sends the HTTP GET request. TTFB (Time to First Byte) measures from navigation start to the first byte of the response. It captures everything up to this point: DNS, TCP, TLS, and server processing time. A slow TTFB means the page is delayed before parsing can even begin.
HTTP/2 allows multiplexing — multiple requests over one connection simultaneously — eliminating the HTTP/1.1 bottleneck of 6 concurrent connections per origin. HTTP/3 adds QUIC (UDP-based), which eliminates TCP head-of-line blocking.
HTML Parsing & the DOM
The browser parses HTML bytes into a tree of nodes called the Document Object Model (DOM).
Parsing is incremental. The browser doesn't wait for the full HTML document before starting — it processes the byte stream as it arrives, building the DOM top to bottom. This is why resource order in HTML matters.
Bytes to DOM
The pipeline:
Bytes → Characters → Tokens → Nodes → DOM tree
The tokenizer reads characters and emits tokens (StartTag, EndTag, Character, DOCTYPE). The tree constructor turns tokens into nodes and inserts them into the growing DOM tree.
Parser-blocking resources
As the parser builds the DOM, it encounters resource references. Their behavior differs critically:
- Images (
<img src="...">) — non-blocking. The parser notes the URL, kicks off a fetch, and moves on. The image renders when it arrives. - Stylesheets (
<link rel="stylesheet">) — render-blocking. The browser won't render anything until the CSSOM is built (see next section). Parsing continues but rendering waits. - Scripts (
<script src="...">) without attributes — parser-blocking. The parser stops, the script is fetched and executed, then parsing resumes.
The preload scanner
Stopping the parser for every script would be catastrophic for performance. Browsers run a preload scanner — a lightweight second pass that looks ahead in the HTML stream while the main parser is blocked. It discovers <img>, <link>, and <script> URLs and dispatches network requests early, even while the parser is stuck. This is why putting critical resources in <head> matters even if the parser can't process them yet.
DOMContentLoaded vs load
DOMContentLoadedfires when the HTML is fully parsed and all deferred scripts have executed. The DOM is ready.loadfires when every resource on the page — images, stylesheets, iframes — has finished loading.
Most application code should run on DOMContentLoaded, not load.
CSS & the CSSOM
CSS is render-blocking. The browser builds a CSS Object Model (CSSOM) from all loaded stylesheets, and no pixels are painted until both the DOM and CSSOM are ready.
Why CSS blocks rendering
When the browser encounters a <link rel="stylesheet">, it must:
- Fetch the stylesheet
- Parse it into the CSSOM
Until both are done, the browser won't render anything — not even content that precedes the stylesheet in the HTML. This prevents a flash of unstyled content (FOUC). The tradeoff is that a slow stylesheet delays the first paint for the entire page.
The CSSOM is a tree of style rules, resolved in cascade order (specificity, origin, order). For every DOM node, the browser finds all matching CSS rules and resolves the final computed style.
What does and doesn't block rendering
| Resource | Blocks rendering? | Blocks parsing? |
|---|---|---|
| External stylesheet | Yes | No |
Inline <style> | Yes (built synchronously) | No |
<link media="print"> | No (low priority) | No |
<script> (no attributes) | Yes (blocks parser) | Yes |
<script defer> | No | No |
<script async> | Possibly | No |
A common trick for loading non-critical CSS without blocking rendering:
<link rel="stylesheet" href="non-critical.css" media="print" onload="this.media='all'">
The browser treats media="print" as low priority — it won't block rendering — then the onload flips it to all once it's downloaded.
JavaScript Loading Strategies
Script loading is the most impactful knob for page load performance. There are four modes.
Default (parser-blocking)
<script src="app.js"></script>
When the parser hits this:
- HTML parsing stops
app.jsis fetched (network request)app.jsexecutes (can read the DOM up to this point)- HTML parsing resumes
The fetch and execution both happen synchronously from the parser's perspective. A 300ms script fetch adds 300ms to the time before content below it can render. This is why render-blocking scripts in <head> are so damaging.
Scripts block parsing because JavaScript can call document.write(), which injects HTML directly into the parse stream. The parser has to stop and let JS run in case it does this.
async
<script async src="analytics.js"></script>
- Parser notes the script, kicks off a parallel fetch, and continues parsing
- When the download finishes, the parser is interrupted and the script executes immediately
- Parser resumes
async scripts execute as soon as they're downloaded — order is not guaranteed. If two async scripts are in the HTML, whichever downloads first runs first. They can execute before or after DOMContentLoaded.
Use for: scripts with no dependencies and no dependents — analytics, ads, independent third-party widgets. Anything that doesn't care about DOM state or other scripts.
defer
<script defer src="app.js"></script>
- Parser notes the script, kicks off a parallel fetch, and continues parsing
- When the download finishes, the script is queued — not executed yet
- After HTML is fully parsed, deferred scripts execute in document order
DOMContentLoadedfires after deferred scripts execute
defer gives you the parallel download of async without the execution order uncertainty. The full DOM is available when deferred scripts run.
Use for: most application scripts. If a script needs the DOM or depends on another script, defer is almost always correct.
type="module"
<script type="module" src="app.js"></script>
Modules are deferred by default — same execution timing as defer. Additional properties:
- Always strict mode
- Top-level
awaitis supported - Each module has its own scope (no accidental globals)
- Fetched with CORS (requires appropriate headers for cross-origin)
- Executed once even if referenced multiple times
Inline modules (<script type="module">) are also deferred, unlike inline classic scripts which execute immediately.
Decision guide
Does the script need the DOM?
├── No → async (analytics, ads, tracking)
└── Yes
Does it depend on other scripts or need to run in order?
├── No → async (still fine if truly independent)
└── Yes → defer (or type="module")
Is it inline?
└── type="module" defers it; classic inline always executes immediately
| Download | Execution | Order | DOM available | |
|---|---|---|---|---|
| Default | Blocks parser | Immediately | Document order | Up to <script> tag |
async | Parallel | On download | Not guaranteed | Not guaranteed |
defer | Parallel | After parse | Document order | Yes |
type="module" | Parallel | After parse | Document order | Yes |
The Render Tree
The render tree combines the DOM and CSSOM to produce the set of nodes that will actually be drawn to the screen.
Not all DOM nodes make it into the render tree:
<head>,<script>,<meta>— excluded (non-visual)- Elements with
display: none— excluded entirely (no space taken) ::beforeand::afterpseudo-elements — included (they produce visual output even though they're not in the DOM)
These are different:
display: none— not in render tree, no space, not accessible to screen readersvisibility: hidden— in render tree, space preserved, invisibleopacity: 0— in render tree, space preserved, invisible, but still painted and composited
For each node in the render tree, the browser resolves its final computed style by applying cascade rules: origin, specificity, then document order.
Layout (Reflow)
Layout computes the exact size and position of every render tree node in the viewport.
This is where percentages become pixels. The browser walks the render tree and calculates the box model for each element: content area, padding, border, margin. Block elements stack vertically; inline elements flow horizontally. Flex and grid add their own algorithms on top.
Layout is expensive. A change to one element can invalidate the layout of its children, siblings, and ancestors. In the worst case — changing the width of a container — the entire page relayouts.
What triggers layout
Any read or write that requires knowing an element's geometry will trigger layout. Writes:
- Changing
width,height,margin,padding,border - Changing
font-size,line-height - Adding or removing DOM nodes
- Changing
displayorposition
Reads that force synchronous layout (the browser must flush pending layout to return an accurate value):
offsetWidth,offsetHeight,offsetTop,offsetLeftclientWidth,clientHeight,clientTop,clientLeftscrollWidth,scrollHeight,scrollTop,scrollLeftgetBoundingClientRect()getComputedStyle()
Layout thrashing
Layout thrashing happens when you interleave reads and writes in a loop, forcing the browser to run layout synchronously on each iteration.
// BAD: forces layout on every iteration
for (const el of elements) {
const width = el.offsetWidth; // read: forces layout
el.style.width = width + 10 + 'px'; // write: invalidates layout
}
// GOOD: batch reads, then writes
const widths = elements.map(el => el.offsetWidth); // all reads first
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px'; // all writes after
});
Browsers batch write-triggered layouts into the next frame (they're asynchronous). But a read after a write forces an immediate synchronous layout — the browser can't defer it because you're asking for a value that depends on the current state. Batching reads before writes gives the browser the chance to defer layout to frame time.
Libraries like fastdom formalize this by scheduling reads and writes in separate phases.
Paint
Paint is the process of filling in pixels — colors, text, borders, shadows, images — for each element in the render tree.
Paint happens per layer. The browser identifies which elements go on which layers, then paints each layer independently. This matters because when something changes, only the affected layer needs to be repainted — not the whole page.
What triggers paint (but not layout)
If a change doesn't affect geometry, the browser can skip layout and go straight to paint:
color,background-colorborder-color,outline-colorbox-shadow,text-shadowbackground-imagechanges
Paint is cheaper than layout but still CPU-bound. Repainting a large layer (full-screen background change, for example) is visibly slow on low-end hardware.
What triggers neither layout nor paint
Some properties bypass both and go straight to compositing:
transformopacityfilter(in most browsers)will-change(when set to any of the above)
These run entirely on the GPU compositor thread — off the main thread — and never cause layout or paint. This is why animating transform: translateX() is smooth even under main thread load, while animating left is not.
Compositing
Compositing is the final step: the browser combines all the painted layers into the final frame using the GPU.
Modern browsers split the page into multiple layers. Each layer is a texture uploaded to the GPU. The compositor assembles them into the final image. This happens on a separate thread from the main thread — which is why compositor-only animations (transform, opacity) are immune to main thread jank.
Layer creation
The browser promotes elements to their own layer when:
will-change: transformorwill-change: opacityis settransform: translateZ(0)ortranslate3d(0,0,0)is used (the GPU hack)- The element is
position: fixed - The element is a
<video>,<canvas>, or<iframe> - The element has a CSS animation or transition on
transformoropacity
The three tiers of rendering cost
| Change | Triggers | Cost |
|---|---|---|
| Width, height, margin, top, left, font-size | Layout → Paint → Composite | Highest |
| Color, background, box-shadow | Paint → Composite | Medium |
| transform, opacity | Composite only | Lowest (GPU) |
When optimizing animations, the goal is to stay in the third tier. Animating a card sliding in with transform: translateX() instead of left moves from tier 1 to tier 3.
Layer explosion
More layers is not always better. Each layer consumes GPU memory (VRAM). A page with hundreds of promoted layers can run out of memory on mobile devices, causing the browser to de-promote layers and fall back to software rendering — making things worse. will-change should be applied only to elements that actually animate, not as a blanket optimization.
Resource Hints
Resource hints are <link> elements that tell the browser to do work early — before it would naturally discover the need.
preload
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/hero.jpg" as="image">
<link rel="preload" href="/critical-chunk.js" as="script">
Fetches a resource the current page needs, at high priority, before the browser would normally discover it. Crucially, preload only fetches — it does not execute or apply the resource. The browser still executes/applies it when encountered in the normal document flow.
The as attribute is required — it tells the browser the resource type so it can set the correct priority and send the right Accept headers. Fonts require crossorigin even on the same origin.
Use preload for:
- The LCP image (hero, above-the-fold)
- Fonts used in above-the-fold text
- Critical JavaScript chunks that are dynamically imported but immediately needed
Unused preloads generate a browser warning and waste bandwidth. Only preload what's definitely needed on this page.
prefetch
<link rel="prefetch" href="/next-page-bundle.js" as="script">
Fetches a resource the next page will need, at low priority (idle time). Stored in the HTTP cache. The browser can ignore it under resource pressure.
Use prefetch for: the JS bundle for the route the user will likely navigate to next. A login page can prefetch the dashboard bundle. A product listing page can prefetch the product detail bundle.
preconnect
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://api.example.com" crossorigin>
Performs DNS lookup + TCP handshake + TLS negotiation for an origin early — before the browser needs to make an actual request. No data is transferred. Eliminates the connection setup cost (typically 100–300ms) from the critical path.
Use preconnect for third-party origins you know you'll need: CDNs, API servers, font providers. Don't overuse it — each preconnect holds open a TCP connection, and connections have a limit.
dns-prefetch
<link rel="dns-prefetch" href="https://analytics.example.com">
DNS lookup only — no TCP or TLS. Very cheap. Use it for origins you might connect to but aren't certain about (lazy-loaded third parties, conditionally loaded resources). Treat it as a lighter-weight preconnect fallback.
modulepreload
<link rel="modulepreload" href="/app.js">
Like preload for ES modules, but also parses and compiles the module (and its static imports) ahead of time. The browser stores the compiled module in the module map, so when the <script type="module"> executes, the module is ready instantly.
Use for entry-point JS modules and their critical dependencies in module-based applications.
Decision guide
| Hint | What it does | When to use |
|---|---|---|
preload | Fetch current-page resource early | LCP image, critical font, entry JS |
prefetch | Fetch next-page resource at idle | Next route's bundle |
preconnect | DNS + TCP + TLS to an origin | CDN, API, font host |
dns-prefetch | DNS only | Uncertain third-party origins |
modulepreload | Fetch + parse + compile ES module | Module entry points |
Critical Rendering Path
The critical rendering path is the sequence of steps the browser must complete before it can render any pixels. Optimizing it means getting to first paint as fast as possible.
The critical path includes:
- HTML fetched and parsed enough to build the initial DOM
- All render-blocking CSS fetched and CSSOM built
- All parser-blocking scripts fetched and executed
- Render tree built from DOM + CSSOM
- Layout computed
- First paint
Any resource on this path that is slow, large, or unnecessary delays first paint.
Minimize render-blocking CSS
Inline the CSS needed for above-the-fold content directly in <head>. It's processed synchronously and never causes a network request:
<head>
<style>
/* Only styles needed for content visible without scrolling */
body { margin: 0; font-family: Inter, sans-serif; }
.hero { height: 100vh; background: #000; }
</style>
<link rel="stylesheet" href="/full.css" media="print" onload="this.media='all'">
</head>
The full stylesheet loads non-blocking via the media="print" trick. Above-the-fold content paints immediately; below-the-fold content picks up styles once the full sheet arrives.
Defer all scripts
Put scripts at the bottom of <body> or use defer. Parser-blocking scripts in <head> are the single most common cause of slow FCP.
<!-- Bad: blocks parser, delays everything below -->
<head>
<script src="app.js"></script>
</head>
<!-- Good: downloads in parallel, executes after parse -->
<head>
<script defer src="app.js"></script>
</head>
Above the fold
Content visible in the viewport without scrolling should paint as fast as possible. That means:
- Its HTML is near the top of the document
- Its CSS is inlined or in a tiny render-blocking stylesheet
- Its images are
preloaded - Its images have explicit
widthandheightattributes (prevents layout shift) - No parser-blocking scripts precede it
Everything below the fold can afford to arrive later.
Connection to Performance Metrics
Each metric measures how far along the rendering pipeline the browser has gotten:
| Metric | What it measures | What affects it |
|---|---|---|
| TTFB | Time to first byte from server | DNS, TCP, TLS, server response time |
| FCP | First Contentful Paint — first text or image painted | Render-blocking CSS and JS, document size |
| LCP | Largest Contentful Paint — largest image or text block | Resource load time of LCP element, render-blocking resources |
| CLS | Cumulative Layout Shift — visual stability | Images without dimensions, dynamic content injected above fold, web fonts causing reflow |
| INP | Interaction to Next Paint — responsiveness | Long JS tasks blocking the main thread, layout thrashing |
FCP is blocked by anything on the critical rendering path. Every render-blocking stylesheet and parser-blocking script pushes FCP later.
LCP is typically an image — the hero photo, an above-the-fold banner. It's delayed by the image download time. preload the LCP image and ensure it's not loaded lazily.
CLS is caused by elements that shift after paint. The most common culprits: images without width/height (browser doesn't know how much space to reserve), ads that expand on load, and web fonts causing a reflow when they swap in. Fix images with explicit dimensions; fix fonts with font-display: optional or font-display: swap with a close fallback font.
INP is about the main thread. Long-running JavaScript tasks (>50ms) block the event loop, making the page feel unresponsive. Layout thrashing is one cause. Others: large synchronous computations, heavy third-party scripts, unvirtualized long lists.