Browser Rendering and Web Performance
"Why is this page slow?" is one of the most common senior frontend questions. A strong answer starts from how the browser turns bytes into pixels, names the metrics that matter, and walks a measure-first process rather than listing tricks. This chapter covers the rendering pipeline, Core Web Vitals, loading strategy, runtime performance, caching and bundle size.
1. What happens when you type a URL
- URL parsing and cache checks (browser, service worker, HTTP cache).
- DNS lookup to find the IP address.
- TCP connection (and TLS handshake for HTTPS). HTTP/3 uses QUIC over UDP and merges these steps.
- HTTP request; the server responds with HTML (possibly after redirects).
- Parsing: the browser builds the DOM from HTML while discovering CSS, scripts, images and fonts.
- Render pipeline (next section) turns DOM and CSS into pixels.
- JavaScript executes and may change the DOM, triggering more rendering. For client-rendered apps, the page is empty until the JS bundle downloads, runs and fetches data.
Each step suggests an optimisation: fewer redirects, CDN and caching, preconnect, compressed and smaller responses, server rendering, less blocking JavaScript.
2. The rendering pipeline
<!--fig:pipeline-->- DOM is built from HTML. CSSOM is built from CSS.
- Render tree: DOM nodes that are visible, combined with computed styles.
- Layout (reflow): compute the size and position of every box.
- Paint: draw pixels into layers (text, colours, borders, shadows).
- Composite: combine layers on the GPU into the final frame.
Blocking behaviour:
- CSS is render-blocking: the browser will not paint until stylesheets in the head are loaded, because it needs them to know what to draw.
- Synchronous scripts are parser-blocking: HTML parsing stops while the script downloads and runs. Use
defer(download in parallel, run after parsing, in order) orasync(run as soon as downloaded, any order) ortype="module"(deferred by default). - A script may wait for pending CSS, because it could query styles.
Which changes cost what
| Change | Triggers | Cost |
|---|---|---|
width, height, margin, top, font-size, adding or removing nodes | layout, paint, composite | highest |
color, background, box-shadow, visibility | paint, composite | medium |
transform, opacity (on their own layer) | composite only | lowest |
That is why animations should use transform and opacity.
Layout thrashing
Reading a layout property (offsetHeight, getBoundingClientRect, scrollTop) forces the browser to compute layout now. Interleaving reads and writes in a loop forces repeated layouts.
// bad: each iteration writes, then reads, so layout is recalculated every time
// for (const el of items) { el.style.width = box.offsetWidth + "px"; }
// good: batch all reads, then all writes
function batchedMeasureThenWrite(items, measure, write) {
const sizes = items.map(measure); // reads
items.forEach((el, i) => write(el, sizes[i])); // writes
return sizes;
}
let layouts = 0, dirty = false;
const el = n => ({
read() { if (dirty) { layouts++; dirty = false; } return 100 + n; }, // a read after a write forces layout
write() { dirty = true; },
});
const nodes = [el(1), el(2), el(3), el(4)];
for (const n of nodes) { n.write(); n.read(); } // interleaved
const interleaved = layouts;
layouts = 0; dirty = false;
const reads = nodes.map(n => n.read()); // all reads first
nodes.forEach(n => n.write()); // then all writes
assert.equal(interleaved, 4);
assert.equal(layouts, 0); // no forced layout during the batch
assert.equal(reads.length, 4);
3. Core Web Vitals and other metrics
Google's Core Web Vitals are field metrics for real-user experience (verify the current thresholds on web.dev, since they are revised occasionally):
| Metric | Measures | "Good" threshold (at the time of writing) |
|---|---|---|
| LCP (Largest Contentful Paint) | how fast the main content appears | 2.5 s or less |
| INP (Interaction to Next Paint) | responsiveness to clicks, taps and keys across the visit | 200 ms or less |
| CLS (Cumulative Layout Shift) | visual stability: unexpected movement | 0.1 or less |
Others: TTFB (time to first byte), FCP (first contentful paint), TBT (total blocking time, a lab proxy for responsiveness), TTI, and bundle size. Assess at the 75th percentile of real users, split by device and network.
Lab versus field: Lighthouse and DevTools give reproducible lab data for debugging; the Chrome UX Report and your own real-user monitoring (RUM) show what users experience. Use both.
Improving each vital
LCP: make the hero resource discoverable and fast. Server-render or pre-render the HTML; put the hero image in the HTML (not injected by JavaScript) with fetchpriority="high"; do not lazy-load it; compress and size images; preload critical fonts and images; use a CDN; reduce render-blocking CSS and JS; cut TTFB.
INP: keep the main thread free. Break up long tasks (over 50 ms), defer non-critical work, reduce JavaScript, avoid expensive handlers and large DOMs, use startTransition or debouncing, and move heavy computation to workers.
CLS: reserve space. Set width and height (or aspect-ratio) on images, videos and embeds; reserve slots for ads and banners; avoid inserting content above existing content; use font-display and size-adjusted fallback fonts; animate with transform.
4. Loading strategy
- Critical path: minimise the bytes and round trips needed for first render. Inline critical CSS; defer the rest.
- Resource hints:
preconnect(early connection to an origin),dns-prefetch,preload(fetch something needed soon),prefetch(fetch for a likely next navigation),modulepreload. - Images: modern formats (AVIF, WebP), responsive
srcset,loading="lazy"for below-the-fold images, correct dimensions, a CDN with on-the-fly resizing. - Fonts: subset, WOFF2,
font-display: swaporoptional, preload the key font, self-host. - Compression: Brotli or gzip for text.
- Third-party scripts (analytics, chat, ads) are a frequent culprit: load them async or after interaction, audit their cost, and sandbox where possible.
5. Rendering strategies
| Strategy | HTML comes from | Strengths | Trade-offs |
|---|---|---|---|
| CSR (client-side rendering) | an empty shell plus JavaScript | simple hosting, rich interactivity | slow first paint, weaker SEO, heavy JS |
| SSR (server-side rendering) | the server, per request | fast first paint, good SEO, fresh data | server cost, TTFB, hydration cost |
| SSG (static generation) | built ahead of time | fastest, cheap, cacheable | stale until rebuilt |
| ISR / revalidation | static, regenerated in the background | fresh enough, fast | more moving parts |
| Streaming SSR | server, in chunks | early shell, slow parts later | complexity |
| Islands / partial hydration, server components | mostly HTML with interactive pieces | less JavaScript shipped | framework support, boundaries to design |
Hydration attaches event handlers to server-rendered HTML. The page looks ready but may not respond until hydration finishes, which hurts INP; mitigate with less JavaScript, selective or lazy hydration and server components.
6. JavaScript and bundle size
- Measure: use a bundle analyser; look for large libraries (date, charts, lodash) and duplicates.
- Code-split by route and by feature with dynamic
import(); lazy-load components that are below the fold or behind interaction. - Tree-shake with ES modules and side-effect-free packages; import functions, not whole libraries.
- Replace heavy dependencies with lighter ones or native APIs.
- Modern syntax targets (no unnecessary polyfills) and minification.
- Budget: set a size budget and fail CI when exceeded.
// a tiny lazy loader: load a module once, on first use
function lazy(loader) {
let promise;
return () => (promise ??= loader());
}
let loads = 0;
const getChart = lazy(async () => { loads++; return { render: () => "chart" }; });
const [c1, c2] = await Promise.all([getChart(), getChart()]);
assert.equal(loads, 1); // concurrent callers share a single load
assert.equal(c1, c2);
7. Runtime performance
- Long tasks: anything over 50 ms on the main thread delays input. Chunk work, yield to the browser, use workers or idle time.
- Virtualise long lists (render only visible rows) instead of thousands of DOM nodes.
- Avoid unnecessary re-renders in frameworks (see the React chapter), but measure first.
- Debounce and throttle high-frequency events; use passive listeners for scroll and touch; use
IntersectionObserverinstead of scroll polling. - Memory: detach listeners, clear timers and subscriptions, avoid retaining detached DOM nodes in closures, and use Chrome DevTools heap snapshots to find leaks.
- Animations:
transformandopacity,requestAnimationFrame, andprefers-reduced-motion.
// windowing: how many rows to render for a viewport
function visibleRange(scrollTop, viewportHeight, rowHeight, total, overscan = 2) {
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
const last = Math.min(total - 1, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan);
return [first, last];
}
assert.deepEqual(visibleRange(0, 400, 40, 10000), [0, 12]); // about a dozen rows rendered out of ten thousand
assert.deepEqual(visibleRange(4000, 400, 40, 10000), [98, 112]);
assert.deepEqual(visibleRange(399960, 400, 40, 10000), [9997, 9999]);
8. Caching
- HTTP caching:
Cache-Control(max-age,immutable,no-store,stale-while-revalidate),ETagand conditional requests. Fingerprint build assets (app.3f9a1c.js) and cache them for a year; keep HTML short-lived. - CDN for static assets and edge caching.
- Service workers and the Cache API enable offline and instant repeat loads, with strategies (cache-first, network-first, stale-while-revalidate).
- Client data cache: libraries such as TanStack Query or SWR cache and dedupe API responses.
localStorageandsessionStorageare synchronous and block the main thread; avoid heavy use. IndexedDB is asynchronous.
9. A measure-first workflow
- Reproduce on a realistic device and network (throttle CPU and network in DevTools).
- Measure: Lighthouse for a first pass, the Performance panel for long tasks and layout, the Network panel for waterfalls and blocking resources, a bundle analyser for size.
- Find the biggest bottleneck (not the easiest fix): LCP resource, main-thread work, third parties, images, waterfalls.
- Change one thing, re-measure, and keep what helps.
- Guard it: performance budgets in CI and RUM dashboards for regressions.
10. Common mistakes
- Optimising without measuring.
- Lazy-loading the hero image (hurts LCP).
- Shipping a giant bundle for a page that needs a fraction of it.
- Images without dimensions (layout shift).
- Unbounded third-party scripts.
- Reading layout properties inside write loops.
- Rendering thousands of DOM nodes with no virtualisation.
- Equating a high Lighthouse score with good real-user experience.
11. Practice questions
- Walk through what the browser does from receiving HTML to showing pixels.
- What is render-blocking? How do
asyncanddeferdiffer? - Explain LCP, INP and CLS and one fix for each.
- Why is
transformcheaper to animate thantop? What is layout thrashing? - Compare CSR, SSR and SSG. What is hydration and what is its cost?
- How would you debug a page that feels slow on a mid-range phone?
- How would you render a list of 100,000 items?
- Design a caching strategy for a static site with an API.