From URL to Pixels: What Really Happens When a Page Loads
10/5/2025 • 12 views • 0 shares

Browsers don’t “just show HTML.” They run a full-blown pipeline that looks a lot like a tiny operating system: a network stack, a rendering engine, a JavaScript engine, a GPU compositor, storage and caches, and a scheduler to keep it all in sync. This post walks through that pipeline end‑to‑end, shows where work happens, and highlights the performance traps you can actually fix.
Read it for free here✨
TL;DR
- DOM: HTML → tokens → nodes → a live object tree you can script.
- CSSOM: CSS rules → cascade → computed styles.
- Render Tree: DOM + CSSOM → only visible boxes.
- Layout: Exact sizes/positions → flow, flex, grid, intrinsic sizing.
- Paint: Draw commands (text, borders, backgrounds, images, effects).
- Layers & Compositing: Split into layers → raster → GPU compositing.
- JS & Event Loop: JS can mutate everything; keep it off the critical path.
If you remember nothing else: optimize critical path, minimize main‑thread work, prefer compositor‑only animations (opacity/transform), and ship less JS.
1) Navigation & Networking: getting bytes on the wire
- You navigate. The browser process receives a URL (address bar, link, redirect). It decides which renderer process will handle it (site isolation means different sites usually get separate processes).
- URL → IP. DNS resolves the hostname. The browser may do preconnect (warm up TCP/TLS) or use HTTP/2 or HTTP/3 for multiplexed streams.
- Security first. For https://, the browser negotiates TLS (certificates, cipher suites). Requests and responses are encrypted.
- Streaming response. HTML bytes arrive in chunks. As soon as the first chunk shows up, the browser forwards it to the renderer so parsing can begin while the rest downloads.
- Caching layers. Before going to the network, the browser checks: bfcache (for same‑tab back/forward), memory cache, HTTP disk cache, and any Service Worker.
Performance hooks you control:
- <link rel="preconnect" href="https://example.cdn"> to reduce handshake latency.
- <link rel="dns-prefetch" href="//example.cdn"> for earlier DNS.
- Service Worker caching strategies for repeated navigations.
2) HTML parsing → DOM tree
- Decode. The HTML parser decodes bytes → characters (UTF‑8, etc.).
- Tokenize. Characters → tokens like <h2>, text nodes, comments.
- Tree build. Tokens → DOM. On <h2>, the engine creates an HTMLHeadingElement and inserts it at the correct place in the tree.
- Error tolerant. HTML’s parser rules auto‑close tags, reorder certain elements, and tolerate broken markup.
- Discovery. As the parser advances, it discovers external resources: CSS, scripts, images, fonts. A preload scanner runs ahead to kick off fetches early.
Script blocking:
- A classic parser‑blocking script (no defer / async) pauses the parser until it’s fetched and executed because it might mutate the DOM.
- Module scripts are deferred by default, execute after parsing but before DOMContentLoaded.
<!-- Worst for initial render: blocks parser and style calculation -->
<script src="/heavy.js"></script>
<!-- Good: fetched in parallel, executed after parsing -->
<script defer src="/app.js"></script>
<!-- Runs ASAP when fetched, independent of parser order (order not guaranteed) -->
<script async src="/ads.js"></script>
3) CSS parsing → CSSOM and the cascade
- Fetch & parse. Linked stylesheets are fetched and parsed into the CSSOM.
- Cascade & specificity. The browser resolves conflicts (author > user > UA, then specificity, then source order). inherit, initial, revert, and cascade layers (@layer) all factor in.
- Computed styles. For every element, it computes the final values (e.g., font-size, display, color).
Render‑blocking reality:
- CSS is render‑blocking because the browser must know styles to build the render tree and layout text correctly. Split critical CSS for the above‑the‑fold region and load the rest later.
<!-- Inline critical CSS for fastest first paint -->
<style>header{position:sticky;top:0} .hero{min-height:60vh}</style>
<link rel="preload" as="style" href="/styles.css" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
4) Render tree: only what can be seen
The render tree merges DOM (what exists) and CSSOM (how it looks), excluding nodes like <head> or anything with display: none. Each render tree node is a box with computed styles that will participate in layout.
5) Layout (a.k.a. reflow): turning boxes into geometry
Layout computes exact sizes and positions. This isn’t trivial:
- Formatting contexts: normal flow (block/inline), flex, grid, table, absolute/fixed positioning.
- Containing blocks and stacking contexts affect how children size/position.
- Intrinsic sizing: images, replaced elements, min-content/max-content size contributions.
- Relative units: %, em, rem, vh/vw, ch.
- Writing modes: vertical text, RTL.
When layout runs, it can be global (full page) or incremental (subtree). Layout is often triggered by:
- DOM changes (add/remove/move nodes)
- Style changes that affect geometry (e.g., font-size, display, width)
- Reading layout‑dependent properties (forcing sync layout): offsetWidth, getBoundingClientRect() right after writes.
// Bad pattern: read → write → read → write on every frame (layout thrash)
const box = el.getBoundingClientRect();
el.style.width = box.width + 10 + 'px';
const now = el.offsetHeight; // forces sync layout again
Fix: batch reads, then writes.
const box = el.getBoundingClientRect(); // read
requestAnimationFrame(() => {
el.style.width = box.width + 10 + 'px'; // write once
});
6) Paint: from styles to draw commands
Once geometry is known, the engine converts boxes into a display list: draw background, borders, shadows, text with shaping/kerning/ligatures, then images, then effects/filters. Some paint operations are cheap (solid fills), some are expensive (blurred shadows, large gradients, complex clip paths).
Text is special. The font subsystem handles fallback, shaping (Ligatures, complex scripts), and subpixel anti‑aliasing.
7) Layers, raster, and GPU compositing
Modern engines don’t paint everything as one giant bitmap. They split the page into layers. Reasons include:
- New stacking context (e.g., opacity < 1, transform, filter)
- Positioned elements (position: fixed/sticky)
- will-change hints
- Video/canvas elements
Pipeline:
- Each layer is painted into tiles.
- Tiles are rasterized (converted into bitmaps), often on raster threads.
- The compositor thread sends layer quads to the GPU, which composites them into the final frame.
Why this matters for animation: If you animate transform or opacity, the browser can often skip layout & paint and just move pre‑rasterized layers on the GPU → smooth 60/120fps. Animating top/left/width/height usually forces layout/paint → jank.
/* Smooth (compositor-only) */
.card { will-change: transform; }
.card:hover { transform: translateY(-6px) scale(1.02); }
/* Janky (layout/paint on every frame) */
.bad:hover { top: -6px; width: calc(100% + 10px); }
8) JavaScript engine & the event loop
The JS engine (V8/SpiderMonkey/JavaScriptCore) parses → compiles → optimizes hot code → executes. It manages a heap and runs garbage collection.
Single main thread. In the renderer, JS runs on the main thread alongside style, layout and paint. Long JS tasks block input and rendering.
Event loop per frame (ideal 16.6ms @60Hz):
- Handle tasks (timers, I/O, user callbacks)
- Drain microtasks (Promises)
- Run requestAnimationFrame callbacks (just before paint)
- Recalculate style/layout if needed
- Paint & composite
console.log('script start'); // taskPromise.resolve().then(() => console.log('microtask')); // microtasksetTimeout(() => console.log('task (timeout)'), 0); // next taskrequestAnimationFrame(() => console.log('rAF before paint'));// Order:
// script start
// microtask
// rAF before paint
// task (timeout)
Web Workers move compute off the main thread, but they can’t touch the DOM directly. Use postMessage to communicate.
9) Input, scrolling, and hit testing
- Input events (pointer/keyboard/touch) are prioritized; if handlers are passive ({ passive: true }), scrolling doesn’t wait for JS.
- Hit testing uses the render tree & stacking contexts to find the target element under the pointer.
- Compositor scrolling: if a scrollable area is on its own layer, scrolling can be smooth without main‑thread involvement.
10) Fonts, images, and media pipelines
- Fonts: font-display controls FOIT/FOUT behavior. Subsetting and variable fonts can reduce payload.
- Images: Decoders turn JPEG/PNG/WebP/AVIF into pixels; srcset/sizes pick the best candidate.
- Video/Audio: Dedicated pipelines handle demuxing, decoding, and rendering, often with hardware acceleration.
/* Avoid invisible text during font load */
@font-face {
font-family: Inter;
src: url(/inter.woff2) format('woff2');
font-display: swap; /* or optional */
}
11) Accessibility & SEO side‑effects
- The browser builds an Accessibility Tree derived from the DOM & computed styles (role, name, state). Semantics matter for AT and SEO.
- Visibility (e.g., display:none) can remove nodes from the A11y tree.
12) Security and process isolation
- Same‑Origin Policy prevents one origin from reading another’s data without permission.
- Site Isolation puts different sites in different renderer processes for memory/spectre safety.
- Sandboxing (iframes, Content-Security-Policy) further restricts capabilities.
13) What repeats after the first frame
Any time your code does something like h2.style.color = 'red':
- Style for that subtree is recalculated.
- If geometry changes → layout.
- If visuals change → paint.
- If layering changes → recomposite.
The browser tries to do the least necessary work — your job is to avoid forcing more.
14) Practical performance playbook
- Ship less JS. Bundle split, lazy‑load routes/components, prefer native features.
- Defer non‑critical JS. Use defer, async, type="module", and avoid parser‑blocking scripts.
- Prioritize critical CSS. Inline above‑the‑fold, load the rest with preload.
- Animate transforms/opacity only. Use will-change sparingly.
- Batch DOM reads/writes. Avoid layout thrashing; use rAF for writes.
- Avoid sync layout reads after writes; use ResizeObserver/IntersectionObserver.
- Optimize images. Use modern formats (AVIF/WebP), proper srcset/sizes, lazy‑load offscreen images.
- Fonts. Use font-display: swap, subset, and limit variants.
- Workers for heavy compute. Keep the main thread free for input and rendering.
- Measure. Use the Performance panel, Lighthouse, and field metrics: FCP, LCP, CLS, INP.
15) Debugging the pipeline in DevTools
- Performance panel: record, then inspect Timings → Scripting/Rendering/Painting.
- Layers/Rendering: show layer borders, paint flashing, scrolling performance issues.
- Coverage: see unused CSS/JS.
- Network: request waterfalls, priority, HTTP/2 stream coalescing.
16) A mental model you can keep
Even a tiny page with a single <h2> will:
- Stream HTML bytes → parse → DOM
- Fetch & parse CSS → CSSOM
- Merge → Render Tree
- Compute Layout
- Paint a display list
- Raster into bitmaps
- Composite layers on the GPU
- Repeat parts of that anytime you scroll or run code.
That’s the real lifecycle of every web page. Once you see the steps, you can decide what to make cheaper, what to avoid, and what to move off the critical path.
Appendix: quick reference snippets
Non‑blocking script loading
<script src="/module.js" type="module"></script>
<script src="/legacy.js" nomodule defer></script>
Preconnect and preload
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="font" href="/inter.woff2" type="font/woff2" crossorigin>
Prevent layout thrash
let width;
function measure() { width = el.getBoundingClientRect().width; }
function mutate() { el.style.width = width + 10 + 'px'; }
measure();
requestAnimationFrame(mutate);
Compositor‑friendly animation
.box { will-change: transform; }
@keyframes pop { to { transform: scale(1.05); } }
.box:hover { animation: pop 150ms ease-out forwards; }If this helped, tap 💚 on Medium and share. I post deep dives like this every week — follow for more.
Happy coding 💻✨
A message from our Founder
Hey, Sunil here. I wanted to take a moment to thank you for reading until the end and for being a part of this community.
Did you know that our team run these publications as a volunteer effort to over 3.5m monthly readers? We don’t receive any funding, we do this to support the community. ❤️
If you want to show some love, please take a moment to follow me on LinkedIn, TikTok, Instagram. You can also subscribe to our weekly newsletter.
And before you go, don’t forget to clap and follow the writer️!
From URL to Pixels: What Really Happens When a Page Loads was originally published in JavaScript in Plain English on Medium, where people are continuing the conversation by highlighting and responding to this story.
![<![CDATA[AI Can Hurt Junior Engineers — A Simple Guide (and How to Avoid It)]]>](https://cdn-images-1.medium.com/max/1024/1*mJ4WFuh3duF0ZmR4aho4Cg.png)
![<![CDATA[⚠️ The Dark Side of Array Methods: Map, Filter, Reduce Pitfalls]]>](https://cdn-images-1.medium.com/max/1024/1*RPWwMHvPwrFRn2_LCpVWHg.png)
![<![CDATA[⚡ 5 JavaScript Performance Traps That Make Your Code Slow]]>](https://cdn-images-1.medium.com/max/1024/1*Os2ruIrTrjgE8btXpUVhsQ.png)