cd ../writing
// performance · production guide

Web Performance 2026 — the budget that actually matters.

Google replaced FID with INP in March 2024. Two years later, half the web is still optimizing for the wrong metric. This is the complete 2026 performance playbook — the thresholds that count for SEO, the techniques that move the needle in production, and the metrics that don't show up in Lighthouse but determine whether your site actually feels fast.

2026 Core Web Vitals 3 primary metrics 15+ techniques © use freely

01The 2026 Core Web Vitals

Three metrics determine whether Google considers your page "good." If 75% of your real-user sessions pass all three, you're in the green band. Below that, you're losing ranking signal and conversion rate simultaneously.

LCP
≤ 2.5s
Largest Contentful Paint. When the main content appears.
INP
≤ 200ms
Interaction to Next Paint. How fast clicks feel.
CLS
≤ 0.1
Cumulative Layout Shift. How much content jumps.

Notice INP, not FID. FID measured only the first input — and 70% of pages passed it trivially because users tend to wait before clicking. INP measures the worst interaction across the session. That's a much harder bar, and it's the reason your scores dropped in 2024 even though your code didn't change.

02Fixing LCP — the four levers

LCP is almost always one of: hero image, hero text block, or above-the-fold video. The fix decomposes into four contributors, each measurable:

  • TTFB (Time to First Byte) — server response time. Should be under 800ms.
  • Resource Load Delay — time between TTFB and when the LCP element starts loading. Should be near zero.
  • Resource Load Time — how long the LCP resource takes to download. Image-dependent.
  • Element Render Delay — time from resource loaded to actually painted.
✓ preload the LCP image
<!-- In <head>, BEFORE any CSS link -->
<link rel="preload"
      as="image"
      href="/hero.avif"
      fetchpriority="high"
      imagesrcset="/hero-800.avif 800w, /hero-1600.avif 1600w"
      imagesizes="(max-width: 800px) 100vw, 1600px">

fetchpriority="high" tells the browser this image is the LCP candidate, jumping it to the front of the network queue. imagesrcset gives the browser responsive options. Combined, these typically cut LCP by 30-50% on image-heavy pages.

Format matters. Use AVIF first, WebP as fallback, JPEG as last resort. AVIF is typically 25-50% smaller than WebP at the same quality. Every CDN supports it now.

03Fixing INP — the long-task problem

INP is dominated by long tasks — JavaScript that blocks the main thread for more than 50ms. Every click that lands during a long task waits for the task to finish, and INP records the worst case.

✓ yield to the browser between heavy work
// Long task that blocks INP
async function processAllItems(items) {
  for (const item of items) {
    heavyProcess(item);  // 10ms each, 200 items = 2s frozen
  }
}

// Split into chunks, yield to browser between
async function processAllItems(items) {
  for (const item of items) {
    heavyProcess(item);
    if (shouldYield()) await yieldToMain();
  }
}

function yieldToMain() {
  return new Promise(resolve => setTimeout(resolve, 0));
}

// Or use the new scheduler API (Chrome 94+)
function yieldToMain() {
  return scheduler.yield();
}

The scheduler.yield() API specifically tells the browser "you can run user input now, then come back to me." It's significantly better than setTimeout(0) for this purpose — that one yields to everything, including other deferred tasks.

04Fixing CLS — the obvious causes

Almost every CLS problem traces back to one of these:

  • Images without dimensions. Always set width and height attributes, or use aspect-ratio CSS. The browser reserves space before the image loads.
  • Web fonts swapping in. Use font-display: optional in production, or preload your critical font files.
  • Ads or embeds expanding. Reserve a min-height container for them, even if the content is taller.
  • JS-injected banners. Cookie banners and notifications that push content down are the #1 cause of CLS regressions.
✓ reserve space for everything
/* Image with proper sizing */
<img src="hero.jpg" width="1600" height="900" alt="...">

/* OR via CSS */
.hero img {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

/* Banner placeholder */
.ad-slot {
  min-height: 250px;     /* reserve known dimension */
  contain: layout;
}

05The JavaScript budget

For the median mobile user, anything over 170 KB of compressed JS starts producing measurable INP regressions. Most React apps ship 300-800 KB. That's where your performance problems are, and no amount of CDN tuning will fix it.

The techniques that actually work:

  • Code splitting per route. Don't ship the admin panel to logged-out users. Don't ship the dashboard to landing-page visitors.
  • Defer non-critical JS. Analytics, A/B tools, chat widgets, customer support — all of these can load after user interaction, not on initial paint.
  • Tree-shake aggressively. Modern bundlers can drop unused exports. lodash should never be imported as a whole module. Use date-fns or dayjs instead of moment.
  • Audit your runtime deps. A typical React app has 30+ npm packages it doesn't need. Run npx depcheck. Be ruthless.

06Image optimization — what works in 2026

The full stack: AVIF format, responsive sizes, lazy loading for below-fold, eager loading for LCP, preconnect to your CDN.

✓ production image markup
<picture>
  <source type="image/avif"
          srcset="img-400.avif 400w, img-800.avif 800w, img-1600.avif 1600w"
          sizes="(max-width: 800px) 100vw, 800px">
  <source type="image/webp"
          srcset="img-400.webp 400w, img-800.webp 800w, img-1600.webp 1600w"
          sizes="(max-width: 800px) 100vw, 800px">
  <img src="img-800.jpg"
       width="800" height="450"
       loading="lazy"
       decoding="async"
       alt="Description">
</picture>

loading="lazy" on every below-fold image. fetchpriority="high" on the LCP image (and ONLY the LCP image). decoding="async" lets the browser decode images off the main thread.

07Fonts — the silent CLS killer

Web fonts cause CLS when they swap in and the text reflows. Three strategies, in order of preference:

  • System fonts. If your design doesn't require a custom typeface, use system stacks. Zero CLS, zero load time.
  • Preloaded + font-display: swap. Preload the font file in head, accept a brief FOUT (Flash of Unstyled Text). Fastest perceived performance.
  • font-display: optional. If the font hasn't loaded in 100ms, use the fallback for the whole session. Zero CLS, but inconsistent typography.
✓ optimized font loading
<!-- Preload critical font files -->
<link rel="preload"
      href="/fonts/inter-var.woff2"
      as="font"
      type="font/woff2"
      crossorigin>

/* CSS: swap fallback styling to reduce CLS */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
  size-adjust: 100%;     /* match fallback metrics */
  ascent-override: 90%;
  descent-override: 22%;
}

The size-adjust, ascent-override, and descent-override descriptors let you align fallback font metrics to your custom font, eliminating the CLS when the swap happens. Use a tool like Fontaine or Capsize to calculate the exact values for your font pair.

08Measure what matters — RUM not Lighthouse

Lighthouse is a synthetic test on a single device, on a single network, at a single moment. Real users have a 100x distribution of conditions. The metric that determines your Google ranking is the 75th percentile of real-user data, not Lighthouse.

Install web-vitals from npm and report to your analytics. The whole thing is 5 lines:

✓ real-user monitoring
import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP(metric => sendToAnalytics('lcp', metric.value));
onINP(metric => sendToAnalytics('inp', metric.value));
onCLS(metric => sendToAnalytics('cls', metric.value));

Now you have the real distribution of metrics from actual users on actual devices. You'll be surprised — and your priority list will reorder accordingly.

09The CDN setup that matters

A CDN with HTTP/3, Brotli compression, edge caching, and proper Cache-Control headers will cut TTFB to under 200ms globally. Cloudflare, Fastly, and Bunny all do this. The difference between them at the 99th percentile is small. The difference between "any CDN" and "no CDN" is enormous.

Set these headers on static assets:

✓ aggressive caching for hashed assets
Cache-Control: public, max-age=31536000, immutable
Vary: Accept-Encoding

immutable tells the browser to never even revalidate. Use this for files with content hashes in their names. The browser will keep them for a year and never make a conditional request.

The mindset shift

Performance is not a checklist you complete once. It's a budget you maintain. Every new feature has a performance cost. Every npm install has a performance cost. The site you ship today is the fastest your site will ever be — unless you treat performance as a recurring engineering discipline, not a one-time optimization.

The teams that win on performance long-term have one thing in common: they reject features that would push them out of the green band. That's not a Lighthouse score. That's a culture. And it's the only thing that compounds.