Browsers and Rendering
A browser turns bytes into pixels. The path from an HTML response to a painted frame is well defined, and knowing it tells you exactly why a page is slow, why a script blocks, and why a CSS change costs more than it looks.
What a browser is made of
Section titled “What a browser is made of”| Component | Job |
|---|---|
| Networking | DNS, connections, HTTP, the cache. |
| HTML parser | Bytes → tokens → nodes → the DOM tree. |
| CSS parser | Stylesheets → the CSSOM. |
| Rendering engine | Blink (Chrome, Edge, Opera), Gecko (Firefox), WebKit (Safari). Builds the render tree, does layout and paint. |
| JavaScript engine | V8 (Blink), SpiderMonkey (Gecko), JavaScriptCore (WebKit). |
| Compositor + GPU | Turns painted layers into the final frame. |
| Browser process / UI | Tabs, address bar, and the process sandbox that isolates sites from each other and from your machine. |
Modern browsers are multi-process: a privileged browser process plus one renderer process per site (site isolation), so a compromised renderer cannot read another site’s memory. Each renderer has one main thread where parsing, style, layout, paint and all your JavaScript run.
The critical rendering path
Section titled “The critical rendering path”The sequence from response bytes to first pixel.
HTML bytes ──parse──► DOM ──┐ ├──► Render tree ──► Layout ──► Paint ──► CompositeCSS bytes ──parse──► CSSOM ─┘1. HTML → DOM
Section titled “1. HTML → DOM”The parser reads bytes, decodes them using the character encoding, tokenises, and builds a tree of nodes. It is incremental — the browser starts building and even rendering before the full document has arrived.
<h1>Title</h1><p>Text with <em>emphasis</em>.</p>body├── h1│ └── #text "Title"└── p ├── #text "Text with " ├── em │ └── #text "emphasis" └── #text "."While parsing, a separate preload scanner races ahead through the raw bytes looking for src and href attributes so downloads start before the parser reaches them. This is why inlining a <script> that document.writes resources is so damaging — it defeats the scanner.
2. CSS → CSSOM
Section titled “2. CSS → CSSOM”Stylesheets parse into an object model, with inheritance and the cascade resolved. CSS is render-blocking: the browser will not paint until it has the CSSOM, because painting with the wrong styles then repainting produces a flash of unstyled content.
A stylesheet’s media attribute lets the browser deprioritise it:
<!-- Blocks rendering --><link rel="stylesheet" href="/main.css">
<!-- Downloaded but not render-blocking on a phone --><link rel="stylesheet" href="/print.css" media="print"><link rel="stylesheet" href="/wide.css" media="(min-width: 1200px)">3. Render tree
Section titled “3. Render tree”DOM + CSSOM combined, containing only what will be painted, with computed styles attached.
Two exclusions that are often confused:
display: none— the element is not in the render tree at all. No box, no space.visibility: hidden— the element is in the render tree, takes up space, but paints nothing.
<head>, <script> and <meta> never appear in the render tree either.
4. Layout (reflow)
Section titled “4. Layout (reflow)”The browser computes the exact geometry of every box: position and size, in pixels, given the viewport. Percentages, em, flex and grid all resolve here. Layout is a tree-wide computation — changing one element’s width can move everything after it.
5. Paint
Section titled “5. Paint”Each box is turned into drawing commands — backgrounds, borders, text glyphs, shadows — recorded onto one or more layers.
6. Composite
Section titled “6. Composite”Layers are uploaded to the GPU and combined into the final frame, applying transforms and opacity. Because the GPU does this, transform and opacity animations can run without touching layout or paint at all. That is the single most useful performance fact about CSS animation.
/* Composite only — cheap, runs off the main thread */.card { transition: transform 200ms, opacity 200ms; }.card:hover { transform: translateY(-4px); }
/* Triggers layout on every frame — expensive */.card-bad { transition: top 200ms, height 200ms; }Where JavaScript runs
Section titled “Where JavaScript runs”On the main thread, the same one doing parsing, style, layout and paint. There is exactly one. While your code runs, nothing renders.
The rendering loop aims for 60 frames per second, which gives roughly 16.7 ms per frame for everything: JS, style, layout, paint, composite. A callback that takes 200 ms drops about 12 frames — the page freezes.
Two ways out:
- Break work up —
setTimeout(fn, 0),requestIdleCallback, or chunked loops that yield. - Move it off-thread — a Web Worker runs on its own thread with no DOM access, communicating by message passing.
const worker = new Worker('/heavy.js');worker.postMessage({ rows: bigDataset });worker.onmessage = (e) => { render(e.data.result); };self.onmessage = (e) => { const result = expensiveComputation(e.data.rows); // does not block the UI self.postMessage({ result });};For the event loop, microtasks and how async/await schedules work, see async JavaScript.
Render-blocking resources
Section titled “Render-blocking resources”Two things stop first paint.
Stylesheets
Section titled “Stylesheets”Any <link rel="stylesheet"> without a non-matching media blocks rendering until it loads. Keep critical CSS small; consider inlining what is needed above the fold and loading the rest asynchronously.
Synchronous scripts
Section titled “Synchronous scripts”A plain <script src="…"> blocks the parser. The browser must stop building the DOM, fetch the script, execute it, and only then continue — because the script might call document.write and change the byte stream it is parsing.
<!-- Parsing stops here until app.js is fetched AND executed --><script src="/app.js"></script>async vs defer
Section titled “async vs defer”<script src="/a.js"></script> <!-- blocks parsing, executes immediately --><script src="/b.js" async></script> <!-- downloads in parallel, executes ASAP --><script src="/c.js" defer></script> <!-- downloads in parallel, executes after parse --><script src="/d.js" type="module"></script> <!-- deferred by default -->| Blocks parsing | Execution time | Order preserved | Sees full DOM | |
|---|---|---|---|---|
| plain | Yes | As soon as fetched, in place | Yes | No |
async |
No (download); Yes (execute) | Whenever the download finishes | No — first-loaded runs first | Maybe |
defer |
No | After parsing, before DOMContentLoaded |
Yes | Yes |
type="module" |
No | Like defer |
Yes | Yes |
async still blocks parsing while it executes; it just does not block during download. Neither attribute has any effect on an inline <script> without src.
The load events
Section titled “The load events”DOMContentLoaded— HTML parsed, deferred scripts executed. Stylesheets and images may still be loading.load— everything finished, including images, stylesheets, iframes and fonts.
document.addEventListener('DOMContentLoaded', () => console.log('DOM ready'));window.addEventListener('load', () => console.log('everything loaded'));Reflow vs repaint
Section titled “Reflow vs repaint”Changing the page re-runs part of the pipeline. How much depends on what you changed.
| Change | Re-runs |
|---|---|
width, height, top, margin, padding, font-size, adding/removing a node |
Layout → paint → composite |
color, background-color, box-shadow, border-radius, visibility |
Paint → composite |
transform, opacity |
Composite only |
Layout (also called reflow) is the expensive one because it can cascade through the whole tree.
Layout thrashing
Section titled “Layout thrashing”The real killer is not one layout — it is forcing many. The browser batches style and layout work, but reading a geometry property forces it to flush immediately so the value is correct.
// BAD: read, write, read, write — forces a synchronous layout every iterationfor (const el of items) { el.style.width = el.offsetWidth + 10 + 'px'; // offsetWidth reads → forced layout}
// GOOD: batch all reads, then all writes — one layout totalconst widths = items.map(el => el.offsetWidth); // read phaseitems.forEach((el, i) => { // write phase el.style.width = widths[i] + 10 + 'px';});Properties that force a synchronous layout when read include offsetTop/Left/Width/Height, clientTop/Left/Width/Height, scrollTop/Left/Width/Height, getComputedStyle() and getBoundingClientRect().
Other useful tools:
will-change: transformhints that an element should get its own compositor layer. Use sparingly — every layer costs GPU memory.content-visibility: autolets the browser skip layout and paint for off-screen subtrees entirely.requestAnimationFrameschedules work just before the next paint, which is where visual updates belong.
Client-side storage
Section titled “Client-side storage”Four mechanisms, different in scope, capacity and who can read them.
| Sent to server | Capacity | Lifetime | API | JS-readable | |
|---|---|---|---|---|---|
| Cookies | Yes, every request | ~4 KB each | Expires/Max-Age |
document.cookie |
Only if not HttpOnly |
| localStorage | No | ~5–10 MB per origin | Until cleared | Sync, string-only | Yes |
| sessionStorage | No | ~5–10 MB | Until the tab closes | Sync, string-only | Yes |
| IndexedDB | No | Large (quota-based, often hundreds of MB) | Until cleared | Async, structured objects | Yes |
// localStorage / sessionStorage: strings only, synchronouslocalStorage.setItem('theme', 'dark');localStorage.getItem('theme'); // => 'dark'localStorage.setItem('user', JSON.stringify({ id: 42 }));JSON.parse(localStorage.getItem('user')); // => { id: 42 }localStorage.removeItem('theme');
// sessionStorage is per-tab: a second tab on the same origin sees nothingsessionStorage.setItem('draft', 'unsaved text');All four are partitioned by origin. https://example.com and http://example.com do not share storage.
IndexedDB is the real database: asynchronous, transactional, indexed, stores structured clones (objects, Blobs, typed arrays). Its raw API is verbose and event-based, so most code uses a small wrapper. Use it for offline data, large caches and anything beyond a few hundred kilobytes.
The Cache API (separate from IndexedDB) stores complete Request/Response pairs and is what service workers use to serve a page offline.
Devtools
Section titled “Devtools”Every browser ships them; the panels map onto the pipeline above.
| Panel | Use it for |
|---|---|
| Elements / Inspector | The live DOM (not your source HTML), computed styles, the box model, which rule won and what it overrode. |
| Console | Logging and an eval prompt in the page’s own context. |
| Network | Every request: method, status, headers, timing waterfall, size vs transferred size, initiator. Throttle to simulate slow links. |
| Performance | Record a timeline. Shows main-thread activity, long tasks, forced layouts, frame drops. |
| Application / Storage | Cookies with all their attributes, localStorage, sessionStorage, IndexedDB, service workers, the cache. |
| Sources / Debugger | Breakpoints, stepping, watch expressions, source maps. |
| Lighthouse | Automated audit of performance, accessibility and best practices. |
Practical habits:
- In Network, “Size” vs “Transferred” tells you whether compression is working; a status of
304or a Size of “(disk cache)” tells you caching is. - Disable cache while devtools is open when testing changes — otherwise you debug yesterday’s assets.
- In Elements, the “Computed” tab shows the final value of every property and links to the rule that set it. Faster than reading stylesheets.
- In Performance, look for tasks over 50 ms — those are what make input feel unresponsive.
- Right-click any request in Network → Copy → Copy as cURL to replay it in a terminal exactly as the browser sent it.
Key points
Section titled “Key points”- One main thread does parsing, style, layout, paint and all your JavaScript — a slow callback freezes the page.
- The pipeline is DOM + CSSOM → render tree → layout → paint → composite; each step down is cheaper.
- CSS blocks rendering; a plain
<script src>blocks parsing. Usedeferfor app code,asynconly for independent scripts. - Animate
transformandopacityto stay on the compositor; animatingtoporheightforces layout every frame. - Batch DOM reads and writes separately to avoid layout thrashing.
- Cookies travel with every request;
localStorage/sessionStorage/IndexedDB stay on the client and are all readable by page JavaScript.