15 · Performance & Core Web Vitals
Measuring and improving real-user performance: the Core Web Vitals (LCP, INP, CLS), the metrics around them, profiling, network and runtime optimization, and bundle budgets. Current as of INP as a Core Web Vital (it replaced FID in 2024) and the
web-vitalsv4+ era.
Positioning
Performance is a feature with revenue and SEO consequences: Core Web Vitals are a Google ranking signal, and latency correlates directly with conversion and bounce. A senior engineer treats performance as a measured, budgeted, continuously-monitored property, not a one-off optimization sprint. The discipline: measure real users (field data), diagnose with lab tools, fix the highest-impact bottleneck, and put a budget in CI so it doesn’t regress.
Foundations: the metrics
Core Web Vitals (the three Google ranks on)
- LCP — Largest Contentful Paint (loading): time until the largest content element (hero image, headline) is rendered. Good ≤ 2.5s. Driven by TTFB, render-blocking resources, resource load time, and client-side render delay.
- INP — Interaction to Next Paint (interactivity): the responsiveness metric that replaced FID in March 2024. Measures the latency of (nearly) all interactions across the page lifecycle — from input to the next paint reflecting it. Good ≤ 200ms. Driven by long tasks blocking the main thread, heavy event handlers, and large re-renders.
- CLS — Cumulative Layout Shift (visual stability): how much visible content shifts unexpectedly. Good ≤ 0.1. Driven by images/ads/embeds without dimensions, injected content, and web fonts (FOUT/FOIT).
Supporting metrics
- TTFB (Time to First Byte) — server + network latency; the floor for LCP.
- FCP (First Contentful Paint) — first pixel of content.
- TBT (Total Blocking Time) — lab proxy for INP; sum of long-task blocking time.
- Long tasks — main-thread tasks > 50ms; the enemy of INP.
Field (RUM) vs Lab data
- Field / RUM — real users (CrUX dataset, your own
web-vitalsreporting). This is what Google ranks on and what reflects reality (devices, networks, geography). INP/LCP/CLS are field metrics. - Lab — synthetic, reproducible (Lighthouse, WebPageTest, DevTools). Great for diagnosing and CI gating, but can’t fully predict field INP. You need both: field to know you have a problem, lab to fix it.
Deep dive
1. Measuring: the web-vitals library + PerformanceObserver
The Google web-vitals library (onLCP, onINP, onCLS, onTTFB, onFCP) reads the underlying Performance APIs and reports field values; send them to your analytics/RUM endpoint via navigator.sendBeacon. Under the hood these use PerformanceObserver entry types (largest-contentful-paint, layout-shift, event, longtask, long-animation-frame). The newer LoAF (Long Animation Frames) API gives much better INP attribution (which script/handler caused the slow frame).
import { onLCP, onINP, onCLS } from 'web-vitals';
const send = (m) => navigator.sendBeacon('/rum', JSON.stringify(m));
onLCP(send); onINP(send); onCLS(send);
2. Improving LCP
- Cut TTFB: CDN, edge/SSR streaming (
07), caching (08,18). - Remove render-blocking CSS/JS: inline critical CSS, defer non-critical JS (
async/defer,01). - Prioritize the LCP resource:
fetchpriority="high"on the hero image,preloadit, ensure the preload scanner can find it (don’t inject it via JS). - Serve right-sized, modern images (AVIF/WebP, responsive
srcset,next/image-style optimization). - Reduce client render delay: SSR/SSG the above-the-fold content so LCP isn’t gated on hydration (
07).
3. Improving INP (the hard one in 2026)
- Break up long tasks: yield to the main thread (
scheduler.yield()where available,setTimeout/postTask, chunked work). - Move heavy work off the main thread: Web Workers (
04) for parsing/computation. - Reduce JS execution: ship less JS, code-split (
14), avoid hydrating everything (islands/RSC,07). - Optimize re-renders: React Compiler / memoization, virtualization for long lists (TanStack Virtual), avoid synchronous layout in handlers (
01layout thrashing). - Use transitions (
useTransition/useDeferredValue,05) to keep input responsive while expensive updates render at lower priority. - Defer non-urgent work until after the interaction paints.
4. Improving CLS
- Always set width/height (or
aspect-ratio) on images, video, embeds, ads so space is reserved. - Reserve space for dynamically injected content (banners, skeletons sized to content).
- Manage fonts:
font-display: optional/swap+ preloading + size-adjust to minimize reflow; usesize-adjust/fallback metrics matching. - Avoid inserting content above existing content after load.
- Use the CSS
contain/content-visibilitywhere appropriate.
5. Network performance (cross-link 18)
- HTTP/2/3 multiplexing, compression (Brotli), CDN edge delivery, caching headers (
immutable,stale-while-revalidate). - Preconnect/dns-prefetch to critical origins; preload critical assets; modulepreload for JS chunks.
- Minimize request waterfalls (don’t over-split,
14; avoid chained dependent fetches — aggregate in a BFF/RSC,12).
6. Runtime performance & profiling
- Chrome DevTools Performance panel — flame charts, long tasks, layout/paint, the new Performance/Insights with LoAF attribution.
- React Profiler / React DevTools — find expensive renders and why a component re-rendered.
- Lighthouse / PageSpeed Insights — lab + CrUX field, prioritized opportunities.
- WebPageTest — filmstrips, connection throttling, real devices.
- The method: measure → find the biggest bottleneck → fix → re-measure. Don’t optimize on intuition.
7. Budgets & guardrails (making it stick)
- Performance budgets: max bundle size per route, max LCP/INP/CLS thresholds. Enforce in CI (Lighthouse CI,
bundlesize,size-limit, the bundler analyzer from14). - Continuous RUM monitoring with alerting on CWV regressions per release.
Worked example: fixing a janky filter interaction (INP)
// BEFORE: typing filters a 10k-row list synchronously → long tasks → INP > 500ms
function List({ rows }) {
const [q, setQ] = useState('');
const visible = rows.filter(r => r.name.includes(q)); // blocks the keystroke paint
return <>{/* ...render thousands of rows... */}</>;
}
// AFTER: keep input responsive + virtualize the list
function List({ rows }) {
const [q, setQ] = useState('');
const deferredQ = useDeferredValue(q); // expensive filter at low priority
const visible = useMemo(() => rows.filter(r => r.name.includes(deferredQ)), [rows, deferredQ]);
// + TanStack Virtual so only ~20 rows are in the DOM
return <VirtualizedRows items={visible} />;
}
The keystroke paints immediately (high priority); the filtered list catches up without blocking input. Virtualization keeps the DOM small so each update is cheap. Measure INP before/after in the field to confirm.
Pitfalls & gotchas
- Optimizing lab scores while field INP/LCP stay bad — real devices/networks differ; trust field data.
- Chasing FID — it’s gone; INP is the interactivity metric now.
- Memoizing everything to fix INP — often the wrong lever; long tasks and over-rendering are usually the cause (and React Compiler handles memo,
05). - Images without dimensions — the most common CLS cause.
- Hydrating the whole page — ships and executes JS that tanks INP; prefer islands/RSC (
07). - Request waterfalls from chained fetches or over-splitting (
14). - No budget in CI — performance silently regresses release by release.
- Layout thrashing in handlers (read-then-write-then-read of layout,
01).
Interview questions
- Name the three Core Web Vitals, what they measure, and “good” thresholds.
- What replaced FID, and why is INP harder to optimize?
- Field vs lab data — why do you need both?
- How would you diagnose and fix a slow LCP? A bad INP? High CLS?
- What causes long tasks and how do you break them up?
- How do
useTransition/useDeferredValuehelp responsiveness? - How does rendering strategy (CSR/SSR/RSC/islands) affect CWV?
- What’s a performance budget and how do you enforce it?
- What’s the LoAF API and why does it matter for INP?
- How do images and fonts cause layout shift, and how do you prevent it?
Recommendations
- Measure field CWV with
web-vitals→ RUM; gate lab CWV + bundle size in CI (Lighthouse CI / size-limit). - Treat INP as the 2026 priority: less JS, fewer long tasks, off-main-thread work, transitions, virtualization.
- Protect LCP with SSR/streaming above-the-fold, prioritized hero image, fast TTFB.
- Kill CLS with dimensions/
aspect-ratioon media and reserved space for injected content + font strategy. - Set and defend performance budgets; alert on per-release regressions.
- Profile with DevTools + React Profiler; fix the biggest bottleneck, then re-measure.
Books & references
- web.dev/vitals and web.dev/learn/performance — Google’s authoritative, current CWV guidance (the source of truth).
web-vitalslibrary (github.com/GoogleChrome/web-vitals) — measure field metrics correctly.- “High Performance Browser Networking” — Ilya Grigorik (free at hpbn.co). The networking foundation (
18). - Addy Osmani’s writing (addyosmani.com, “Image Optimization”) — practical perf, image, and JS-cost guidance.
- Lighthouse / PageSpeed Insights / WebPageTest docs — the lab tooling.
- “Designing for Performance” — Lara Hogan. Budgets and the perf-culture side.
- Chrome DevTools Performance docs — flame charts, LoAF, Insights.
Connections
01-browser-engines-and-rendering.md— CRP, layout/paint/composite, layout thrashing — the mechanics behind the metrics.07-rendering-strategies.md— SSR/islands/RSC vs hydration cost is the biggest CWV lever.14-build-tools-and-bundlers.md— bundle size, code-splitting, and budgets drive LCP/INP.18-networking-and-protocols.md— TTFB, HTTP/2/3, caching, compression, preloading.05-react-internals-and-patterns.md— transitions, deferred values, the Compiler, virtualization for INP.04-the-web-platform.md— Web Workers, PerformanceObserver, IntersectionObserver,content-visibility.