Part 6 — From bytes to pixels
Every part until now was about getting bytes from Netflix's servers to your machine. This part is the last mile: what your browser does with those bytes once they arrive, from the first byte of HTML to a fully painted, interactive screen.
Chapter 11 — Browser Rendering: From Bytes to Pixels
The browser received the HTML from Netflix. Now it has to turn those bytes into the page you see on screen. This is one of the most complex pipelines in software engineering.
The Simple Version (5-year-old)
Imagine you receive a set of LEGO instructions (HTML). Before you can build:
- Read the instructions (parse HTML → DOM tree — know what pieces you have).
- Check the color guide (parse CSS → CSSOM — know how each piece should look).
- Plan what to build (Render Tree — combine structure + style, ignore hidden pieces).
- Figure out where each piece goes (Layout — calculate position and size).
- Paint it (Paint — fill in colors, draw text, add images).
- Put it on display (Composite — GPU assembles final picture, shows it on screen).
Each step must happen before the next. This is the critical rendering path.
The Critical Rendering Path
HTML bytes arrive (compressed, encrypted)
│
▼ 1. Decode
Decompressed UTF-8 HTML text
│
▼ 2. Parse HTML
DOM Tree (Document Object Model)
│
├──────────────────────────────────────────┐
│ (parallel: discover CSS links, JS files) │
▼ 3. Parse CSS │
CSSOM Tree (CSS Object Model) │
│ │
▼ 4. Combine ▼ 5. Download + Execute
Render Tree JavaScript
(DOM + CSSOM, visible nodes only) (modifies DOM/CSSOM)
│
▼ 6. Layout (Reflow)
Geometry: position and size of every element
│
▼ 7. Paint
Pixel instructions: what color, text, images at each pixel
│
▼ 8. Composite
GPU assembles layers into the final frame
│
▼
Screen (60fps = new frame every 16.67ms)
Step 1: Receiving and Decoding
HTML arrives in chunks (streamed). Browser starts parsing as bytes arrive — it does not wait for the full page.
Bytes received: [TLS decrypt] → [Brotli decompress] → [UTF-8 decode]
e.g.: 0xEF 0xBB 0xBF ... → <html><head><title>Netflix</title>...
Streaming parse advantage: Browser can discover <link rel="stylesheet"> and <script> tags early and begin downloading CSS/JS while HTML is still arriving.
Step 2: Parsing HTML → DOM Tree
The DOM (Document Object Model) is a tree representation of the HTML structure.
<html>
<head>
<title>Netflix</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav id="header">
<img src="/logo.png" alt="Netflix">
</nav>
<main>
<h1>Watch anywhere.</h1>
</main>
</body>
</html>
Becomes:
Document
└── html
├── head
│ ├── title: "Netflix"
│ └── link (stylesheet: /style.css)
└── body
├── nav#header
│ └── img (src: /logo.png)
└── main
└── h1: "Watch anywhere."
Parser Blocking — The Most Important Performance Rule
The HTML parser blocks (stops) when it encounters a <script> tag without async or defer:
<head>
<script src="/analytics.js"></script> ← Parser STOPS here
... ← This HTML is not parsed until script finishes
</head>
Why? JS can call document.write() which modifies HTML.
Parser must let JS run before continuing.
This is why render-blocking scripts are bad for performance.
Solution — async and defer:
<script src="/analytics.js" async></script>
→ Downloaded in parallel. Executed whenever downloaded (may be before/after DOM ready).
→ Does not block parsing. Good for independent scripts (analytics, ads).
<script src="/app.js" defer></script>
→ Downloaded in parallel. Executed AFTER HTML is fully parsed.
→ Does not block parsing. Good for scripts that need the full DOM.
→ Executed in order of appearance (unlike async).
<script type="module" src="/app.js"></script>
→ Implicitly deferred. Supports ES module imports.
CSS also blocks rendering (but not parsing):
<link rel="stylesheet" href="/style.css">
CSS must be fully downloaded and parsed before any rendering happens.
Why: rendering without CSS would show unstyled content, then flash to styled = bad UX.
(This is called FOUC — Flash Of Unstyled Content)
Step 3: Parsing CSS → CSSOM
The CSSOM (CSS Object Model) is the CSS equivalent of the DOM — a tree of style rules.
body { font-size: 16px; color: #333; }
nav { background: #e50914; }
h1 { font-size: 2rem; font-weight: bold; }
CSSOM represents computed styles for each element:
CSSOM:
body: { font-size: 16px, color: #333 }
nav: { background: #e50914 }
h1: { font-size: 32px (2rem × 16px base), font-weight: bold }
CSS specificity and cascade rules are applied during CSSOM construction. Child elements inherit parent styles.
Step 4: Building the Render Tree
The Render Tree combines DOM + CSSOM. It contains only visible nodes with their computed styles.
DOM Node CSSOM Rule Render Tree Node?
<html> - Yes (container)
<head> display:none(implicit) NO
<title> - NO (head is hidden)
<body> display:block Yes
<nav> display:flex Yes + styles applied
<img> display:inline Yes + styles applied
<script> - NO (not visual)
<div style="display:none"> display:none NO (explicitly hidden)
Nodes with display: none are excluded. Nodes with visibility: hidden ARE included (they take up space, but paint as invisible).
Step 5: Layout (Reflow)
Layout (also called Reflow) calculates the exact position and size of every node in the render tree.
Browser viewport: 1440px wide × 900px tall
Compute for each element:
body: x=0, y=0, width=1440, height=900
nav: x=0, y=0, width=1440, height=64
img: x=16, y=12, width=92, height=40
main: x=0, y=64, width=1440, height=836
h1: x=120, y=200, width=1200, height=48
Layout is expensive. Changing an element's geometry (width, height, position) can force the browser to recalculate the geometry of ALL other elements — this cascades.
What triggers layout:
- Changing
width,height,padding,margin,position,font-size,display - Adding/removing DOM elements
- Reading layout properties (
offsetWidth,getBoundingClientRect()) — browser must flush pending layouts to give accurate values - Resizing the window
Avoid layout thrashing:
// BAD — triggers layout 1000 times
for (let i = 0; i < 1000; i++) {
element.style.width = element.offsetWidth + 1 + 'px'; // read then write → layout!
}
// GOOD — batch reads, then writes
const width = element.offsetWidth; // read once
for (let i = 0; i < 1000; i++) {
element.style.width = (width + i) + 'px'; // write without reading
}
Step 6: Paint
Paint is converting the render tree into actual pixel instructions.
Paint operations recorded (not executed yet — this is a "paint list"):
- Fill rectangle (0, 0, 1440, 64) with color #e50914 (nav background)
- Draw image at (16, 12, 92, 40) (Netflix logo)
- Draw text "Watch anywhere." at (120, 200) in font 32px bold #fff
- Draw rectangle border (1, solid, #ccc) at (0, 64, 1440, 1)
...
Paint is done per layer. Browser creates multiple layers for efficiency:
- Static content (background, text) — one layer
- Animated elements — separate layers (GPU handles animation without repainting)
- Fixed-position elements (sticky nav) — separate layer (scrolls independently)
What triggers paint:
- Changing
color,background,border,box-shadow,opacity - Layout changes (always trigger paint too)
Step 7: Composite
Compositing is the GPU's job: take all the painted layers and assemble them into the final frame.
Layer 1: Background + content (painted by CPU)
Layer 2: Netflix logo (image)
Layer 3: Fixed header (separate layer — doesn't repaint on scroll)
Layer 4: Video player overlay
Layer 5: Modal dialog (if open)
GPU composites all layers → final frame → displayed on screen
Why separate layers help performance:
- Animations on their own layer (
transform,opacity) only trigger composite — no layout, no paint. GPU handles it at 60fps with no CPU involvement.
Promoting an element to its own layer:
/* Tells browser: put this on its own GPU layer */
.animated-element {
will-change: transform; /* modern, explicit hint */
transform: translateZ(0); /* old trick — also creates layer */
}
Danger: Too many layers = too much GPU memory. Only promote elements that genuinely need it.
JavaScript and the Main Thread
The browser has one main thread that handles:
- HTML parsing
- JavaScript execution
- CSS parsing
- Layout
- Paint
If JS runs for more than 16ms (one frame), the browser cannot render a new frame → janky animation or unresponsive UI.
Main thread timeline:
[Parse HTML] [CSS] [JS runs 200ms!] [Layout] [Paint] [Composite]
↑
During these 200ms: no rendering, no user input handled
Page appears frozen to user
Solutions:
// Move heavy work off the main thread using Web Workers
const worker = new Worker('/heavy-computation.js');
worker.postMessage({ data: largeDataset });
worker.onmessage = (e) => updateUI(e.data);
// Main thread free to render while worker computes
// Break up long tasks using setTimeout/requestAnimationFrame
function processChunk(items, start) {
const end = Math.min(start + 50, items.length);
for (let i = start; i < end; i++) {
processItem(items[i]);
}
if (end < items.length) {
requestAnimationFrame(() => processChunk(items, end)); // yield to browser
}
}
Core Web Vitals
Google's metrics for measuring real-user page experience. Netflix engineers optimize for these:
LCP — Largest Contentful Paint
Time until the largest visible element (hero image, headline) is rendered.
Good: < 2.5s
Needs improvement: 2.5s–4.0s
Poor: > 4.0s
How to improve LCP:
• Preload the hero image: <link rel="preload" href="/hero.jpg" as="image">
• Use a CDN (image served from nearby edge)
• Server-Side Rendering (HTML arrives with content, not shell)
• Optimize image format (WebP, AVIF = 50% smaller than JPEG)
• Inline critical CSS (no render-blocking CSS file fetch)
CLS — Cumulative Layout Shift
Total unexpected layout shift during the page's life. Measures visual stability.
Good: < 0.1
Needs improvement: 0.1–0.25
Poor: > 0.25
Common causes:
• Images without width/height attributes (browser doesn't know size until loaded → shifts content)
• Web fonts loading (text reflows when font loads)
• Ads loading and expanding
Fix:
<img src="/logo.png" width="92" height="40"> (reserve space before image loads)
font-display: swap; (show fallback font, swap when web font loads — may shift)
font-display: optional; (use web font only if already cached — no shift)
INP — Interaction to Next Paint
Time from a user's interaction (click, tap, keypress) to the next visible browser response. INP replaced First Input Delay (FID) as a Core Web Vital in March 2024 because it captures responsiveness across the page's interactions, not only the first one.
Good: ≤ 200ms
Needs improvement: 200ms–500ms
Poor: > 500ms
Caused by: JS blocking the main thread
Fix: Break up long tasks, use Web Workers, reduce JS bundle size
TTFB — Time to First Byte
Time from request sent to first byte of response received. Measures server speed.
Good: < 200ms
Includes:
DNS lookup + TCP handshake + TLS handshake + server processing
Optimize with:
• Use a CDN (edge server is nearby)
• Server-side caching (Redis, Memcached)
• Efficient database queries
• SSR with streaming (send HTML as soon as first chunk is ready)
Resource Hints — Telling the Browser What's Coming
<!-- Preload: fetch this now, high priority, you'll definitely need it -->
<link rel="preload" href="/hero.jpg" as="image">
<link rel="preload" href="/critical.css" as="style">
<link rel="preload" href="/font.woff2" as="font" crossorigin>
<!-- Prefetch: fetch this soon, low priority, next navigation probably needs it -->
<link rel="prefetch" href="/next-page.html">
<link rel="prefetch" href="/video/trailer.mp4">
<!-- Preconnect: open TCP+TLS connection to this origin now, don't fetch yet -->
<link rel="preconnect" href="https://api.netflix.com">
<link rel="preconnect" href="https://cdn.nflxvideo.net">
<!-- DNS-prefetch: resolve DNS for this domain now (weaker than preconnect) -->
<link rel="dns-prefetch" href="//api.netflix.com">
Service Workers — Offline and Caching
A Service Worker is a JavaScript file that runs in a background thread and intercepts all network requests.
Browser request lifecycle with Service Worker:
Browser → [SW intercepts] → Cache? HIT → return from cache (0ms)
MISS → fetch from network → cache → return
Use cases:
• Offline support: serve cached content when network is unavailable
• Background sync: queue writes when offline, send when online
• Push notifications: receive server push even when app is closed
• Precaching: download assets proactively during install
• Request rewriting: modify requests (add auth headers, change URLs)
Across the series
- The network half of page speed (DNS, TCP, TLS, and TTFB) is measured
phase by phase by the
httptracelab in Chapter 17. A reused connection cut a request from 1,223 ms to 54 ms in testing. - Why latency has a physical floor, and why "deploy closer to users" matters more than optimising code: OSI guide, Layer 1.
Key Takeaways
- The critical rendering path: HTML → DOM → (+ CSS → CSSOM) → Render Tree → Layout → Paint → Composite.
- Parser-blocking scripts (
<script>) halt HTML parsing — usedeferorasync. - CSS is render-blocking — it must be downloaded before the browser paints anything.
- Layout (reflow) calculates geometry — expensive, cascades to all elements. Avoid forcing it in loops.
- Paint converts the render tree to pixel instructions per layer.
- Composite is GPU work — transforms and opacity animations skip layout and paint entirely.
- Keep JS off the main thread — use Web Workers for CPU-heavy tasks.
- Target: new frame every 16.67ms (60fps). Any JS over 16ms causes jank.
- Core Web Vitals: LCP (content visible), CLS (layout stable), INP (responsive to input).
- Resource hints (
preload,preconnect,prefetch) tell the browser what to fetch ahead of time.