Web Performance
Core Web Vitals, bundles, and the cost of JavaScript.
Web Performance
A page that loads slowly is one that users abandon. The tricky part: "loading" isn't a single event. It's a sequence of measurements, each reflecting a different aspect of experience.
Analogy
Judging a restaurant isn't one timer. There's the time from walking in to being seated (when does the page start?), the time until the main course lands on the table (LCP — the thing you actually came for), whether the server keeps bumping your glass and rearranging the silverware while you're mid-bite (CLS — the layout won't sit still), and how long it takes a waiter to come back after you raise a hand (INP — did the interaction get a response). A place can seat you in thirty seconds and still feel awful if the entrée takes an hour and a busser keeps yanking your plate. Web performance is the same: three separate stopwatches on three separate parts of the meal.
Core Web Vitals
Google defined three metrics that together describe the user experience of loading, stability, and interactivity.
| Metric | What it measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP — Largest Contentful Paint | When the main content appears | ≤ 2.5 s | ≤ 4.0 s | > 4.0 s |
| CLS — Cumulative Layout Shift | How much the page jumps during load | ≤ 0.1 | ≤ 0.25 | > 0.25 |
| INP — Interaction to Next Paint | Response time after user input | ≤ 200 ms | ≤ 500 ms | > 500 ms |
LCP — making the main content arrive fast
LCP is the render time of the largest image or text block visible in the viewport. Common culprits:
- An unpreloaded hero image discovered late in the waterfall
- A server that takes too long to respond (high TTFB)
- Render-blocking scripts that delay paint
Fix: preload the LCP candidate, use SSG or RSC to eliminate server latency, and ensure no scripts block the critical path.
<!-- Tell the browser to fetch this early -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
CLS — stopping the page from jumping
Layout shift happens when content moves after the user starts reading. Typical causes:
- Images without explicit
widthandheightattributes - Fonts loading and reflow affecting surrounding text
- Ads injected after initial paint
Fix: always give images and embeds explicit dimensions. Load fonts with font-display: optional or swap and set size-adjust to reduce reflow.
<img src="/product.jpg" width="800" height="600" alt="Product" />
INP — keeping interactions snappy
INP (replacing FID in 2024) measures the worst interaction latency across the whole session. Long tasks on the main thread block responses.
Fix: break long tasks with scheduler.yield(), move heavy computation off the main thread into Web Workers, and reduce the amount of JavaScript that runs on each interaction.
JavaScript is the most expensive kilobyte
100 KB of compressed JavaScript costs far more than 100 KB of a compressed image. An image is decoded once and drawn. JavaScript must be downloaded, parsed, compiled, and executed — on every page load, on every device, at every network speed.
The ratio is roughly: 1 KB of JS ≈ 2 ms of overhead on a mid-range device. A 300 KB bundle adds ~600 ms before a single interaction is possible.
Mitigation:
- Code splitting — ship only the JS needed for the current route
- Tree shaking — remove unused exports at build time
- React Server Components — zero client bundle for server-only logic
- Dynamic imports — lazy-load heavy components on demand
The resource waterfall
Every resource the browser needs — HTML, CSS, fonts, images, scripts — appears as a bar in the waterfall. Bars start when the browser discovers the resource and end when it's ready to use.
Two hints move resources earlier:
preload— I need this for the current page, fetch it now (LCP images, critical fonts)prefetch— I might need this for the next page, fetch it when idle
<!-- Preload: critical, current page -->
<link rel="preload" as="font" href="/inter.woff2" crossorigin />
<!-- Prefetch: future navigation -->
<link rel="prefetch" href="/about" />
Abusing preload for non-critical resources wastes bandwidth and delays the resources that actually matter.
Image optimisation
- Use
<picture>orsrcsetto serve appropriately sized images for each viewport - Prefer WebP or AVIF over JPEG/PNG — 30–50% smaller at equivalent quality
- Set
loading="lazy"on below-the-fold images — the browser skips them until the user scrolls near
Bundle analysis
Run next build with ANALYZE=true (via @next/bundle-analyzer) to see which packages dominate the bundle. Common culprits: date libraries, icon sets imported in full, heavyweight UI components.
Tools in the wild
6 tools- cliLighthousefree tier
Lab-based audit for LCP / CLS / TBT, with concrete optimization opportunities.
- servicePageSpeed Insightsfree tier
Google's hosted Lighthouse + real-world CrUX field data side by side.
- serviceWebPageTestfree tier
Detailed waterfalls + filmstrips from real devices on real networks worldwide.
- serviceVercel Analyticsfree tier
RUM Core Web Vitals captured from production traffic; routed by page + route.
- libraryweb-vitals (npm)free tier
Tiny library that captures CWV in the browser and POSTs them anywhere.
- libraryBundle Analyzerfree tier
Visualize Next.js JS bundles per route; the fastest way to find the LCP-killing import.