01 · Browser Engines & the Rendering Pipeline
The substrate. Everything else in this library — JavaScript, React, your design system — ultimately exists to manipulate the data structures and pipeline described here. If you understand how a browser turns bytes into pixels, most performance and rendering questions answer themselves.
Positioning
A browser is a specialized operating system for running untrusted code and documents. A frontend engineer who treats it as a black box hits a ceiling: they can write React but can’t explain why a transform animation is smooth while a top animation jank, why hydration is expensive, or why a 200 ms script blocks the first paint. This file is the foundation the rest depend on.
Foundations: the anatomy of a browser
A modern browser is several cooperating engines, not one program:
- Browser engine / UI shell — tabs, address bar, the chrome around the page.
- Rendering engine — turns HTML/CSS/JS into pixels. The big three lineages:
- Blink (Chrome, Edge, Brave, Opera, Electron) — forked from WebKit in 2013.
- WebKit (Safari, all iOS browsers by Apple mandate) — the original from KHTML.
- Gecko (Firefox) — Mozilla’s independent engine.
- JavaScript engine — compiles and runs JS. V8 (Blink/Node/Deno), JavaScriptCore/Nitro (WebKit), SpiderMonkey (Gecko). Covered in depth in
02-javascript-deep-dive.md. - Networking stack — sockets, TLS, HTTP/1.1–3, cache. See
18-networking-and-protocols.md. - Graphics / compositor — rasterization and GPU compositing (Skia in Blink).
- Storage — cookies, localStorage, IndexedDB, Cache API.
Modern browsers are multi-process (Chrome’s “site isolation”): a browser process, one renderer process per site (sandboxed), a GPU process, network process, and utility processes. This is a security boundary (a compromised renderer can’t read another site’s memory) and a stability boundary (one tab crashing doesn’t kill the browser). It’s also why postMessage and structured cloning exist — processes don’t share memory.
Deep dive
1. From URL to bytes
- Navigation & DNS — resolve hostname to IP (see networking file), open a TCP/QUIC connection, TLS handshake.
- Request — HTTP request sent; server responds with bytes. The first byte timing is TTFB.
- Preload scanner — before the main parser runs, a lightweight scanner skims the incoming HTML for
<script src>,<link>,<img>and kicks off those downloads in parallel. This is why putting critical resources in the initial HTML matters and why client-side injected resources are slower.
2. The Critical Rendering Path (CRP)
This is the canonical pipeline. Memorize it:
Bytes → Characters → Tokens → Nodes → DOM
\
HTML ──parse──▶ DOM ──┐ ├──▶ Render Tree ──▶ Layout ──▶ Paint ──▶ Composite ──▶ Pixels
CSS ──parse──▶ CSSOM ┘
a) Parsing HTML → DOM. The HTML parser tokenizes and builds the DOM tree (Document Object Model) — a tree of node objects. The parser is incremental and can construct DOM as bytes arrive. HTML parsing is lenient (it recovers from malformed markup per the spec’s error-recovery rules).
b) Parsing CSS → CSSOM. CSS builds the CSSOM (CSS Object Model), also a tree, with computed-style inheritance resolved top-down. CSS is render-blocking: the browser won’t paint until it has the CSSOM, because it can’t know an element’s final appearance without all the rules. This is why <link rel=stylesheet> in <head> blocks first paint, and why critical CSS inlining helps.
c) Scripts and the parser. A plain <script> is parser-blocking: when the parser hits it, it stops building the DOM, fetches (if external) and executes the script synchronously, then resumes. Worse, because scripts can read computed styles, an inline script can be blocked behind CSSOM construction. The modifiers:
defer— download in parallel, execute after DOM is complete, in order. Best default for app scripts.async— download in parallel, execute as soon as ready, not in order. For independent scripts (analytics).type="module"— deferred by default; supports import graphs.
d) Render tree. DOM + CSSOM combine into the render tree: only the nodes that will be painted. display:none nodes are excluded entirely; visibility:hidden nodes are in the tree but invisible (they still take layout space). <head>, <script>, metadata never appear.
e) Layout (reflow). The browser computes the geometry — exact position and size of every render-tree node in the viewport, resolving relative units, flexbox/grid, line-breaking. Output is the “box tree.” Layout is global-ish: changing one element’s width can cascade. Triggering layout is one of the two expensive operations.
f) Paint. Convert the box tree into paint records (draw commands): fill this rect, draw this text, this border, this shadow. The browser splits the page into paint layers (e.g., elements with transform, opacity, will-change, position:fixed, video, canvas get their own layer).
g) Rasterization & compositing. Paint records are rasterized into bitmaps (tiles), often on the GPU via Skia. The compositor thread then assembles layers into the final frame and hands it to the GPU. Crucially, the compositor can transform and blend existing layers without re-running layout or paint on the main thread.
3. Why transform/opacity are “free” and top/width are not
This is the single most important practical takeaway:
| You animate… | Pipeline stages re-run | Cost |
|---|---|---|
width, height, top, left, margin | Layout → Paint → Composite | Expensive; can jank |
color, background, box-shadow | Paint → Composite | Medium |
transform, opacity (on a composited layer) | Composite only | Cheap; GPU; runs off main thread |
The compositor runs on its own thread, so transform/opacity animations stay at 60 fps even if the main thread is busy with JavaScript. This is why the rule is “animate only transform and opacity.” will-change: transform hints the browser to promote an element to its own layer ahead of time (use sparingly — each layer costs GPU memory).
4. The render loop, frames, and the event loop’s home
The display refreshes ~60 Hz (16.67 ms/frame; 120 Hz on newer devices). Per frame the browser ideally does:
Input events → requestAnimationFlame callbacks → style → layout → paint → composite
│
(if main thread idle) → requestIdleCallback ──────┘
requestAnimationFrame(cb)runscbright before style/layout — the correct place to make visual changes for animation, batched into the frame.requestIdleCallback(cb)runs in leftover time at the end of a frame — for non-urgent work (this is conceptually what React’s scheduler emulates for time-slicing; see05).- The JavaScript event loop lives in the renderer’s main thread and is interleaved with rendering. Long JS tasks (>50 ms) starve rendering and input → jank and poor INP. This is why “yield to the main thread” (
await scheduler.yield(),setTimeout, breaking up work) matters. The full event-loop model (macrotasks, microtasks) is in02.
5. Forced synchronous layout (layout thrashing)
Layout is normally batched — the browser invalidates and recomputes lazily. But if your JS writes a style and then reads a layout property in the same tick, you force a synchronous recompute:
// BAD — layout thrash: read forces a sync layout each iteration
for (const el of boxes) {
el.style.width = el.offsetWidth + 10 + 'px'; // write then read in a loop
}
// GOOD — batch reads, then batch writes
const widths = boxes.map(el => el.offsetWidth); // read phase
boxes.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px'; }); // write phase
Layout-triggering reads include offsetTop/Width/Height, getBoundingClientRect(), scrollTop, getComputedStyle(), clientWidth. Libraries like FastDOM formalize the read/write batching. React’s virtual DOM is partly about avoiding accidental thrash by batching mutations.
6. V8 and Blink, briefly (engine internals teaser)
V8 compiles JS via a pipeline: Ignition (bytecode interpreter) → Sparkplug (fast baseline compiler) → Maverick/TurboFan (optimizing JIT) with deoptimization when assumptions break. Objects use hidden classes (maps) and inline caches for fast property access; keeping object shapes stable is a real perf lever. Full treatment in 02-javascript-deep-dive.md. The DOM itself is implemented in C++ in Blink and exposed to JS via bindings — crossing that JS↔C++ boundary repeatedly (e.g., touching the DOM in a tight loop) has measurable cost.
7. Storage and the cache, briefly
- HTTP cache — governed by
Cache-Control,ETag, etc. (see18). - Cookies — small, sent on every request;
HttpOnly,Secure,SameSite(see17). - localStorage / sessionStorage — synchronous, ~5 MB, string-only, blocks the main thread (avoid for hot paths).
- IndexedDB — asynchronous, large, structured; the real client database.
- Cache API — programmable request/response cache, the backbone of service-worker offline (see
04).
Worked example: diagnosing a slow first paint
Symptom: blank screen for 2 s, then everything appears at once.
Likely chain, in CRP terms: a render-blocking <link> to a large CSS file (or a @import chain that serializes), plus a parser-blocking synchronous <script> in <head>. The browser can’t paint until CSSOM is ready, and can’t finish DOM until the script runs. Fixes, in order of leverage:
- Inline critical CSS, load the rest with
media/preloadswap. - Add
deferto app scripts so DOM construction isn’t blocked. preconnect/preloadthe critical font and hero image so the preload scanner starts them early.- Reduce TTFB upstream (server/edge — see rendering strategies and networking files).
Pitfalls & gotchas
- Treating the DOM as cheap. Every DOM read can force layout; every write invalidates it. Batch.
- Animating layout properties and blaming React for jank that is actually the compositor doing layout+paint every frame.
- Overusing
will-change— promoting hundreds of layers exhausts GPU memory and slows things down. - Assuming
localStorageis free — it’s synchronous and on the main thread. - Forgetting that CSS blocks rendering but a deferred script does not — script placement and CSS size are different levers.
- Ignoring the preload scanner — resources injected by JavaScript miss it and load late.
Interview / self-test questions
- Walk me through everything that happens from typing a URL to seeing pixels. (Navigation → DNS/TCP/TLS → request → preload scan → parse HTML→DOM and CSS→CSSOM → render tree → layout → paint → composite.)
- Why is animating
transformsmoother than animatingleft? (Compositor-only vs layout+paint+composite, and the compositor runs off the main thread.) - Difference between
asyncanddefer? (Both download in parallel;deferexecutes in order after DOM complete,asyncexecutes ASAP out of order.) - What is layout thrashing and how do you fix it? (Interleaved read/write forcing synchronous layout; batch reads then writes.)
- Why is CSS render-blocking but a deferred script isn’t? (Can’t compute final appearance without full CSSOM; deferred scripts don’t gate the parser.)
- What does
display:nonevsvisibility:hiddendo to the render tree and layout? (Excluded entirely vs present-but-invisible and still occupying space.) - Why are browsers multi-process and what does that imply for sharing memory between tabs/workers? (Security + stability isolation; no shared memory → message passing / structured clone / SharedArrayBuffer with COOP/COEP.)
Recommendations
- Internalize the CRP as your default debugging lens before reaching for framework-level explanations.
- Use Chrome DevTools Performance panel: learn to read the flame chart, the “Rendering” track, and “Layout Shift” / “Long Tasks” markers.
- Budget the main thread. Keep individual tasks under ~50 ms; yield for anything longer.
- Prefer compositor-friendly animations; reserve layout changes for genuine layout changes.
- Measure with field data (Core Web Vitals, see
15), not just your fast laptop.
Books & references
- MDN Web Docs — the canonical reference for every API mentioned here.
- “How browsers work” — Tali Garsiel & Paul Irish (web.dev / html5rocks classic). The foundational long-form article; still the best free overview of parsing → render tree → layout → paint.
- “Inside look at modern web browser” (4-part series) — Mariko Kosaka, developer.chrome.com. Multi-process architecture, the compositor, and input handling, beautifully illustrated.
- web.dev/rendering-performance and “Avoid large, complex layouts and layout thrashing” — the practical performance canon from the Chrome team.
- “High Performance Browser Networking” — Ilya Grigorik (free at hpbn.co) for the network half of the pipeline.
- V8 blog (v8.dev/blog) — for engine internals (Ignition, TurboFan, hidden classes).
Connections
02-javascript-deep-dive.md— the JS engine, event loop, and memory that run inside this pipeline.04-the-web-platform.md— the DOM/CSSOM APIs you manipulate, plus workers that escape the main thread.15-performance-and-core-web-vitals.md— LCP/INP/CLS map directly onto stages of this pipeline.18-networking-and-protocols.md— the “URL to bytes” half.07-rendering-strategies.md— hydration cost and why SSR shifts work across this pipeline.