Skip to main content

How browser works when entering request and rendering stages

Browser rendering pipeline - the sequence of steps from URL input to pixels on screen, split into two phases: network and rendering.

Theory

TL;DR

  • Network phase: DNS lookup, TCP handshake, TLS (HTTPS only), HTTP request, server response
  • Rendering phase: HTML parsing (DOM), CSS parsing (CSSOM), JS execution, render tree, layout, paint, composite
  • JS without async or defer blocks HTML parsing completely
  • Animating transform skips layout and paint - the compositor handles it on the GPU
  • Reading a layout property right after writing a style forces synchronous layout - avoid this in loops

Pipeline overview

javascript
// From Enter key to pixels - every step in order: // 1. DNS: example.com → 93.184.216.34 // 2. TCP: SYN → SYN-ACK → ACK // 3. TLS: certificate + cipher negotiation (HTTPS only) // 4. HTTP: GET /index.html HTTP/1.1 // 5. Response: server sends HTML bytes (chunked or full) // 6. DOM: parser builds node tree from HTML incrementally // 7. CSSOM: parser builds style tree from CSS (must complete fully) // 8. JS: executes, may modify DOM or CSSOM // 9. Render: DOM + CSSOM = render tree (visible nodes only) // 10. Layout: calculate exact position and size of each node // 11. Paint: rasterize pixels per layer (back to front) // 12. Composite: GPU merges layers → final frame on screen

Each step produces output that the next one needs. If one stalls, everything after it waits.

DNS resolution

The browser needs an IP address before it can open a connection. It checks four places in order:

  1. Its own DNS cache (browser-level, TTL is often very short)
  2. The OS hosts file (static overrides, handy in local dev)
  3. The OS resolver cache
  4. A recursive DNS server - your ISP's or a public one like 8.8.8.8

The recursive server, if it has no cached answer, walks the DNS hierarchy: root nameservers → TLD nameserver (.com, .io) → authoritative nameserver for the domain. On a warm cache, this takes under 1ms. On a cold lookup, add 20-120ms depending on geography.

TCP and TLS handshake

TCP requires three messages before any data moves:

  • SYN: client opens the conversation
  • SYN-ACK: server accepts
  • ACK: client confirms, connection is ready

For HTTPS, a TLS handshake follows. The server sends its certificate, both sides agree on a cipher suite, and they derive session keys. HTTP/2 runs on TLS and multiplexes all resource requests over one connection, removing the per-request handshake cost. HTTP/3 uses QUIC and combines TCP and TLS setup into a single round trip.

HTTP request and server response

The browser sends a GET request with headers: Host, User-Agent, Accept-Encoding, and any cookies for that domain. The server replies with a status code and a body. A 200 with HTML starts the rendering pipeline. A 301 or 302 restarts the whole process with a new URL.

Chunked transfer encoding lets the browser start parsing HTML before the full response arrives. This is why pages sometimes render progressively - the browser does not wait.

Building DOM and CSSOM

HTML parsing is incremental. The browser tokenizes the byte stream, creates nodes, and attaches them to the tree as it reads. It does not wait for the full document.

CSS is different. The CSSOM has to be complete before the browser uses any of it, because later rules can override earlier ones. A large unoptimized stylesheet blocks the render tree from forming. This is one reason splitting CSS by media query or deferring non-critical styles matters.

JavaScript and the render-blocking problem

A <script> tag without attributes stops HTML parsing entirely. The browser fetches the script, executes it, then resumes. This exists because JS can call document.write() or modify the DOM mid-parse.

async downloads the script in parallel but still pauses the parser at execution time. defer downloads in parallel and executes only after the HTML is fully parsed, in document order. For most scripts, defer is the right choice.

I have watched teams spend two hours debugging load order issues because they put async on scripts that depended on each other. Execution order with async is not guaranteed.

Render tree

The render tree is not the DOM. It combines each visible DOM node with its computed styles from the CSSOM. <head>, <script>, and any element with display: none are excluded. An element with visibility: hidden stays in the tree - it still takes up space and affects layout.

Layout (reflow)

Layout computes the exact box for every render tree node: position, width, height, margins, padding - all relative to the viewport. This is where the CSS box model actually runs.

Layout is expensive. Changing the width of a parent can trigger recalculation for all its children. Reading a layout property (offsetHeight, getBoundingClientRect) right after writing a style property forces the browser to flush its pending layout queue synchronously. This is called layout thrashing, and it kills frame rate fast.

Paint and composite

Paint converts each layer into actual pixels: colors, text glyphs, borders, shadows. Layers are painted back to front (painter's algorithm). Each layer is rasterized independently.

Composite happens on the GPU. Elements with transform, opacity, will-change, or position: fixed often get their own compositor layer. The GPU merges all layers and sends the final frame to the screen. This is why transform animations are smooth: they never trigger layout or paint, only composite.

Common mistakes

Render-blocking scripts in <head>

html
<!-- blocks parsing while fetching and executing --> <head> <script src="app.js"></script> </head> <!-- fetches in parallel, executes after parse - no blocking --> <head> <script src="app.js" defer></script> </head>

Layout thrashing in a loop

javascript
// Bad: read forces layout flush, write marks it dirty again elements.forEach(el => { const h = el.offsetHeight; // read: synchronous layout el.style.height = (h + 10) + 'px'; // write: layout dirty again }); // Good: batch reads first, then batch writes const heights = elements.map(el => el.offsetHeight); // one layout elements.forEach((el, i) => { el.style.height = (heights[i] + 10) + 'px'; });

Animating geometry properties

css
/* Triggers layout + paint + composite every frame */ .slow-animation { transition: width 0.3s, left 0.3s; } /* Triggers composite only - no layout, no paint */ .fast-animation { transition: transform 0.3s, opacity 0.3s; }

Missing defer on third-party scripts

Adding analytics or chat widgets without defer can add hundreds of milliseconds to Time to Interactive on slow connections. These scripts almost never need to run before the HTML is parsed.

Real-world usage

  • Chrome DevTools Performance tab: yellow = JS execution, purple = layout, green = paint, thin green bars = composite
  • Lighthouse "Eliminate render-blocking resources" flag catches stylesheets and scripts delaying First Contentful Paint
  • React and Vue minimize the number of DOM mutations that trigger layout and paint - they do not bypass any of these stages
  • will-change: transform on an element promotes it to its own compositor layer before an animation starts, avoiding the mid-animation promotion cost

Follow-up questions

Q: What is the critical rendering path?
A: The sequence DOM, CSSOM, render tree, layout, paint. Optimizing it means reducing the number of render-blocking resources and the byte size of each resource that participates in this path.

Q: What is the difference between reflow and repaint?
A: Reflow recalculates geometry and is triggered by size, position, or font changes. Repaint redraws pixels without changing geometry, triggered by color or background changes. Reflow always causes repaint. Repaint does not cause reflow.

Q: Why does visibility: hidden behave differently from display: none in terms of layout?
A: display: none removes the element from the render tree entirely - it takes no space. visibility: hidden keeps it in the tree with full layout impact, so the element still occupies space and affects surrounding elements.

Q: How does HTTP/2 change the network phase?
A: HTTP/2 multiplexes all requests over one TCP connection, removing the per-resource handshake cost. It also compresses headers with HPACK and supports server push. HTTP/3 goes further with QUIC, eliminating TCP head-of-line blocking.

Q: Senior scenario: you read el.offsetWidth and then set el.style.width inside a requestAnimationFrame callback, repeating this on every frame. What happens and why?
A: Reading offsetWidth forces the browser to synchronously flush its pending layout queue to return a fresh value. Writing to style.width marks layout dirty again. On the next frame, the read forces another synchronous layout. This is layout thrashing inside rAF - it fires a forced layout on every single frame and is one of the most common causes of dropped frames.

Examples

Tracing a page load in Chrome DevTools

Open DevTools, go to the Network tab, click any HTML request, and open the Timing panel. You will see each network stage broken out:

  • "DNS Lookup": time spent in DNS resolution
  • "Initial Connection": TCP handshake duration
  • "SSL": TLS negotiation (HTTPS only)
  • "Time to First Byte": server processing plus response start
  • "Content Download": time receiving the HTML bytes

Switch to the Performance tab and record a reload. The flame chart on the Main thread maps directly to the rendering pipeline: purple blocks are layout, green are paint, yellow are script execution. Composite shows up as thin green bars on the Compositor thread.

Script loading strategies compared

html
<!-- Default: parser blocks during fetch AND execute --> <script src="heavy-lib.js"></script> <!-- async: fetches in parallel, executes immediately when ready --> <!-- order not guaranteed, use for independent scripts only --> <script src="analytics.js" async></script> <!-- defer: fetches in parallel, executes after HTML fully parsed --> <!-- order preserved, safe for app code that reads DOM --> <script src="app.js" defer></script> <script src="components.js" defer></script> <!-- components.js always runs after app.js -->

Rule of thumb: defer for anything that touches the DOM, async for truly independent scripts where order does not matter (error trackers, analytics beacons).

Compositor layers and animation performance

javascript
const box = document.querySelector('.box'); // Slow: triggers full pipeline (layout + paint + composite) every frame function animateSlow() { let x = 0; function tick() { x += 2; box.style.left = x + 'px'; // layout dirty → reflow → repaint requestAnimationFrame(tick); } requestAnimationFrame(tick); } // Fast: runs on compositor thread, zero layout or paint function animateFast() { let x = 0; function tick() { x += 2; box.style.transform = `translateX(${x}px)`; // compositor only requestAnimationFrame(tick); } requestAnimationFrame(tick); }

Add will-change: transform to the element's CSS to promote it to its own compositor layer before the animation starts. This avoids the cost of layer promotion happening mid-animation on the first frame.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?