Web Performance Engineering
Performance engineering on the backend is a discipline: you have SLOs, you measure percentiles from real traffic, you profile before optimizing, and you do not trust a single number from your laptop. Frontend performance deserves the exact same rigor, and this post applies that SRE mindset to the browser. The good news for a backend engineer is that the methodology transfers directly — measure at the 75th percentile from real users, find the bottleneck, fix the thing that actually moves the metric. What is new is which metrics matter and what moves them, and that is what we will cover.
This builds on the browser-as-a-platform post: if you understand the rendering pipeline and the event loop, the performance levers follow naturally.
Measure like an SRE: field data at p75
The single most important discipline: optimize against real-user data, not your laptop. Your development machine is a high-end device on a fast network; your users are on mid-range phones on flaky connections. There are two kinds of measurement and you need both:
- Lab data (synthetic) — tools like Lighthouse and WebPageTest run a page in a controlled environment. Great for debugging and catching regressions in CI, because it is reproducible. But it is one run on one simulated device — not what your users experience.
- Field data (Real User Monitoring, RUM) — actual measurements from actual visitors’ browsers, collected via the
web-vitalslibrary or a RUM provider, aggregated. This is ground truth.
Google evaluates Core Web Vitals at the 75th percentile of real users — meaning 75% of visits must hit the “good” bar. That p75 framing should feel exactly like a backend latency SLO, because it is the same idea: you do not chase the average, you make the bad-but-common case acceptable.
Core Web Vitals: the three numbers that matter
Google’s Core Web Vitals are three metrics capturing loading, responsiveness, and visual stability. They are worth knowing precisely because they drive both user experience and search ranking.
| Metric | Measures | Good (p75) | Needs work | Poor |
|---|---|---|---|---|
| LCP — Largest Contentful Paint | Loading: when the biggest visible element renders | ≤ 2.5 s | 2.5–4.0 s | > 4.0 s |
| INP — Interaction to Next Paint | Responsiveness: lag from interaction to visual response | ≤ 200 ms | 200–500 ms | > 500 ms |
| CLS — Cumulative Layout Shift | Visual stability: how much content jumps around | ≤ 0.1 | 0.1–0.25 | > 0.25 |
A note on currency: INP replaced FID (First Input Delay) as a Core Web Vital in March 2024. INP is the stricter, more honest metric — where FID only measured the delay before the first interaction was handled, INP measures the full latency (input delay + processing + next paint) across all interactions on the page, reporting roughly the worst. If you read older material referencing FID, INP is its modern successor, and it is harder to pass.
Each metric maps to a different part of the platform, so each has its own fixes.
LCP: get the biggest thing on screen fast
LCP is when the largest content element in the viewport (usually a hero image, a big heading, or a video poster) finishes rendering. It is dominated by the critical rendering path — the sequence from “HTML arrives” to “meaningful content painted.” Slow LCP almost always comes from one of:
- Slow server response (TTFB). If the HTML is slow to arrive, everything downstream is late. This is backend territory — caching, CDN, faster origin.
- Render-blocking resources. CSS (and synchronous JS) in the
<head>blocks rendering until fetched and parsed. Inline critical CSS, defer the rest. - The LCP resource loading late. If your hero image is discovered late in the parse or is huge, it paints late.
The highest-leverage fixes:
|
|
These are resource hints — preconnect opens the TCP+TLS connection to an origin before you need it (saving the handshake round-trips), and preload tells the browser to fetch a critical resource it would otherwise discover late. Used surgically on the LCP resource and critical origins, they directly cut LCP. Used indiscriminately, they hurt by contending for bandwidth — so hint only what matters.
INP: keep the main thread free
INP is a direct consequence of the single-threaded event loop. When a user clicks, the browser cannot paint the response until your JavaScript event handler finishes and the thread is free to render. A heavy handler — or heavy work scheduled around it — delays that next paint, and the user feels sluggishness. Fixes are all about not blocking the main thread:
- Break up long tasks. Any task over ~50 ms is a “long task” that can delay interaction. Chunk heavy work and yield to the event loop (
await scheduler.yield()where supported, orsetTimeout), so the browser can paint between pieces. - Move CPU work off-thread. Parsing, sorting large datasets, crypto, image work — push it into a Web Worker so the main thread stays responsive.
- Reduce JavaScript. The cheapest fast handler is the one that does less. Less framework overhead, less work per interaction, fewer re-renders.
- Defer non-urgent updates. Update what the user must see immediately; schedule the rest (analytics, secondary UI) for later.
The mental model: INP is the latency SLO of your UI thread. Treat a slow interaction the way you would treat a slow endpoint — profile it (the Performance panel shows you exactly which function ate the frame), find the expensive work, and get it off the critical path.
CLS: stop the page from jumping
CLS measures unexpected layout shifts — content moving after it has already rendered, the maddening experience where you go to tap a button and an ad loads above it and you tap the wrong thing. It is usually caused by a small, fixable set of issues:
- Images and videos without dimensions. If you do not tell the browser how big a media element will be, it reserves no space, and everything below jumps when it loads. Always set
widthandheight(or anaspect-ratio) so the browser reserves the box up front. - Ads, embeds, and iframes injected without reserved space. Reserve a min-height container.
- Web fonts causing a reflow when they swap in (FOUT). Mitigate with
font-displaystrategy and font metric overrides (below). - Dynamically injected content (banners, “you have a new message”) pushing existing content down. Insert it in reserved space or overlay it.
CLS is often the easiest Core Web Vital to fix because the causes are concrete and mechanical: reserve space for anything whose size you know in advance.
The bandwidth levers: images, fonts, code
Beyond the three vitals, three categories of asset dominate page weight, and each has a standard optimization playbook.
Images — usually the single biggest win
Images are typically the heaviest thing on a page, so this is where the largest LCP and bandwidth gains hide.
- Modern formats. Serve AVIF (best compression) or WebP (broadly supported) instead of JPEG/PNG — often 30–50% smaller at equal quality.
- Responsive images. Use
srcset/sizesso a phone downloads a phone-sized image, not your 4K hero. - Lazy-load below the fold.
loading="lazy"defers off-screen images until the user scrolls near them — but never lazy-load your LCP image, which you want eager and preloaded.
|
|
Fonts — a common, sneaky cost
Web fonts block text rendering and cause layout shift. The playbook: self-host fonts (avoids a third-party connection), subset them to only the characters you use, serve WOFF2, preload the critical font, and set font-display: swap so text shows immediately in a fallback and swaps when the web font arrives. Pair swap with font metric overrides (size-adjust, ascent-override) so the fallback and real font occupy the same space and the swap does not shift layout.
JavaScript — ship less of it
JS is expensive twice: the bytes to download and the CPU to parse and execute. The build-pipeline post covered the mechanisms; the performance framing is:
- Code-split by route so the initial load ships only what the landing page needs, lazy-loading the rest via dynamic
import(). - Tree-shake aggressively (ESM imports) so unused library code never ships.
- Audit the dependency budget. A single heavy date or charting library can dwarf your own code. Measure bundle size in CI and treat regressions as build failures.
Caching and the CDN: the backend half of frontend speed
A large share of frontend performance is infrastructure, which should feel familiar.
- Put static assets on a CDN. Serving from an edge node near the user cuts latency dramatically versus a single origin. This directly improves TTFB and LCP.
- Cache aggressively with content hashing. This is why bundlers emit hashed filenames (
app-a1b2c3.js): the content hash means you can serve the file withCache-Control: max-age=31536000, immutable— cache it for a year — because a new build produces a new filename. The HTML that references it stays short-lived; the heavy hashed assets are cached forever. This pattern (long-cache the hashed assets, short-cache the HTML entry point) is the standard and it is extremely effective. - Compress on the wire. Serve text assets with Brotli (better than gzip for JS/CSS/HTML).
Browser ──► CDN edge (nearby)
│ hit? → serve cached, immutable, Brotli-compressed (fast)
│ miss? → fetch from origin, cache at edge, then serve
▼
Origin
A practical workflow
Putting the SRE mindset to work, the loop looks like this:
- Measure the field. Pull p75 LCP, INP, CLS from RUM (Search Console’s Core Web Vitals report or your RUM provider). This tells you which metric is actually failing for real users.
- Reproduce in the lab. Use Lighthouse / the Performance panel to reproduce and diagnose the specific failing metric, throttling to a mid-tier device and slow network so the lab resembles the field.
- Find the bottleneck. LCP slow? Trace the critical path — TTFB, render-blocking resources, the LCP element’s load. INP slow? Profile the slow interaction’s long task. CLS high? Find the shifting element.
- Fix the one thing. Apply the targeted fix (preload the hero, offload the heavy handler, reserve the image’s space). Resist shotgun optimization.
- Guard against regression. Run Lighthouse in CI with budgets, and watch the field metrics after deploy. Performance rots without a ratchet.
This is exactly how you would chase a latency regression on the backend: measure the percentile, reproduce, profile, fix the bottleneck, add a guardrail. The frontend is not a different discipline — it is the same discipline pointed at a different runtime. Master the three vitals and the levers that move them, and “make the site fast” becomes an engineering task with a clear method rather than a vague aspiration.
Sources:
Comments