PAUL CHONGSenior Software Engineer

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.

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:

  1. Browser DNS cache
  2. OS DNS cache (/etc/hosts, system resolver cache)
  3. Recursive resolver (your ISP or a public resolver like 8.8.8.8)
  4. 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

  • DOMContentLoaded fires when the HTML is fully parsed and all deferred scripts have executed. The DOM is ready.
  • load fires 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:

  1. Fetch the stylesheet
  2. 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

ResourceBlocks rendering?Blocks parsing?
External stylesheetYesNo
Inline <style>Yes (built synchronously)No
<link media="print">No (low priority)No
<script> (no attributes)Yes (blocks parser)Yes
<script defer>NoNo
<script async>PossiblyNo

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:

  1. HTML parsing stops
  2. app.js is fetched (network request)
  3. app.js executes (can read the DOM up to this point)
  4. 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>
  1. Parser notes the script, kicks off a parallel fetch, and continues parsing
  2. When the download finishes, the parser is interrupted and the script executes immediately
  3. 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>
  1. Parser notes the script, kicks off a parallel fetch, and continues parsing
  2. When the download finishes, the script is queued — not executed yet
  3. After HTML is fully parsed, deferred scripts execute in document order
  4. DOMContentLoaded fires 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 await is 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
DownloadExecutionOrderDOM available
DefaultBlocks parserImmediatelyDocument orderUp to <script> tag
asyncParallelOn downloadNot guaranteedNot guaranteed
deferParallelAfter parseDocument orderYes
type="module"ParallelAfter parseDocument orderYes

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)
  • ::before and ::after pseudo-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 readers
  • visibility: hidden — in render tree, space preserved, invisible
  • opacity: 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 display or position

Reads that force synchronous layout (the browser must flush pending layout to return an accurate value):

  • offsetWidth, offsetHeight, offsetTop, offsetLeft
  • clientWidth, clientHeight, clientTop, clientLeft
  • scrollWidth, scrollHeight, scrollTop, scrollLeft
  • getBoundingClientRect()
  • 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-color
  • border-color, outline-color
  • box-shadow, text-shadow
  • background-image changes

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:

  • transform
  • opacity
  • filter (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: transform or will-change: opacity is set
  • transform: translateZ(0) or translate3d(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 transform or opacity

The three tiers of rendering cost

ChangeTriggersCost
Width, height, margin, top, left, font-sizeLayout → Paint → CompositeHighest
Color, background, box-shadowPaint → CompositeMedium
transform, opacityComposite onlyLowest (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

HintWhat it doesWhen to use
preloadFetch current-page resource earlyLCP image, critical font, entry JS
prefetchFetch next-page resource at idleNext route's bundle
preconnectDNS + TCP + TLS to an originCDN, API, font host
dns-prefetchDNS onlyUncertain third-party origins
modulepreloadFetch + parse + compile ES moduleModule 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:

  1. HTML fetched and parsed enough to build the initial DOM
  2. All render-blocking CSS fetched and CSSOM built
  3. All parser-blocking scripts fetched and executed
  4. Render tree built from DOM + CSSOM
  5. Layout computed
  6. 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 width and height attributes (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:

MetricWhat it measuresWhat affects it
TTFBTime to first byte from serverDNS, TCP, TLS, server response time
FCPFirst Contentful Paint — first text or image paintedRender-blocking CSS and JS, document size
LCPLargest Contentful Paint — largest image or text blockResource load time of LCP element, render-blocking resources
CLSCumulative Layout Shift — visual stabilityImages without dimensions, dynamic content injected above fold, web fonts causing reflow
INPInteraction to Next Paint — responsivenessLong 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.