---
name: web-performance
description: "Web performance: Core Web Vitals (LCP, INP, CLS), image optimization, code splitting, caching strategies, bundle analysis, Lighthouse. Use when a page is slow and Core Web Vitals, images, bundles or caching have to be addressed."
tags: [performance, core-web-vitals, lighthouse, optimization, frontend, caching]
version: "2025.1"
---

# Web Performance Optimization

## Core Web Vitals (2024-2025)

Google uses Core Web Vitals as ranking signals. All pages should meet the "Good" thresholds.

| Metric | Good | Needs Improvement | Poor | What It Measures |
|--------|------|-------------------|------|-----------------|
| **LCP** | <= 2.5s | <= 4.0s | > 4.0s | Largest Contentful Paint  -  loading speed |
| **INP** | <= 200ms | <= 500ms | > 500ms | Interaction to Next Paint  -  responsiveness |
| **CLS** | <= 0.1 | <= 0.25 | > 0.25 | Cumulative Layout Shift  -  visual stability |

## Image Optimization

```tsx
// Next.js Image component (automatic optimization)
import Image from 'next/image';

function HeroSection() {
  return (
    <section>
      {/* LCP image: priority + eager loading */}
      <Image
        src="/hero.jpg"
        alt="Hero banner showing product lineup"
        width={1200}
        height={600}
        priority  // Preloads  -  use for above-the-fold LCP images
        sizes="100vw"
        quality={85}
      />

      {/* Below-the-fold: lazy load (default) */}
      <Image
        src="/feature.jpg"
        alt="Product feature"
        width={600}
        height={400}
        sizes="(max-width: 768px) 100vw, 50vw"
        placeholder="blur"
        blurDataURL="data:image/jpeg;base64,..."
      />
    </section>
  );
}

// Native HTML with srcset and sizes
// <img
//   src="photo-800.jpg"
//   srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w"
//   sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 800px"
//   alt="Product photo"
//   loading="lazy"
//   decoding="async"
//   width="800"
//   height="600"
// />
```

### Modern Image Formats

```html
<picture>
  <source srcset="photo.avif" type="image/avif" />
  <source srcset="photo.webp" type="image/webp" />
  <img src="photo.jpg" alt="Fallback for older browsers" loading="lazy" />
</picture>
```

## Code Splitting

```tsx
// React.lazy for route-level splitting
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() =>
  import('./pages/Analytics').then((mod) => ({ default: mod.AnalyticsPage }))
);

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  );
}

// Dynamic import for heavy libraries
async function generateChart(data: ChartData) {
  const { Chart } = await import('chart.js/auto');
  const canvas = document.getElementById('chart') as HTMLCanvasElement;
  new Chart(canvas, { type: 'line', data });
}

// Next.js: next/dynamic with SSR control
import dynamic from 'next/dynamic';

const HeavyEditor = dynamic(() => import('@/components/RichTextEditor'), {
  ssr: false,
  loading: () => <EditorSkeleton />,
});
```

## Tree Shaking

```typescript
// Import only what you need (tree-shakeable)
import { debounce, throttle } from 'lodash-es'; // NOT 'lodash'
import { format, parseISO } from 'date-fns'; // NOT import * as dateFns

// Barrel exports can hurt tree shaking  -  use direct imports
import { Button } from '@/components/Button'; // Direct
// NOT: import { Button } from '@/components'; // Barrel  -  may pull entire lib

// Mark side-effect-free in package.json
// {
//   "sideEffects": false,   // or ["*.css"] for CSS-only side effects
// }
```

## Bundle Analysis

```bash
# Next.js
ANALYZE=true next build        # with @next/bundle-analyzer

# Vite
npx vite-bundle-visualizer     # generates stats.html

# Webpack
npx webpack-bundle-analyzer stats.json
```

```javascript
// next.config.js with bundle analyzer
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // next config
});
```

## Caching Strategies

```typescript
// Service Worker caching with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

// Precache static assets
precacheAndRoute(self.__WB_MANIFEST);

// Cache-first for images (long-lived)
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }),
    ],
  })
);

// Stale-while-revalidate for CSS/JS
registerRoute(
  ({ request }) => request.destination === 'style' || request.destination === 'script',
  new StaleWhileRevalidate({ cacheName: 'static-resources' })
);

// Network-first for API calls
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 3,
  })
);
```

### HTTP Caching Headers

```typescript
// Next.js API route / middleware
export async function GET() {
  return new Response(JSON.stringify(data), {
    headers: {
      // Immutable assets (hashed filenames)
      'Cache-Control': 'public, max-age=31536000, immutable',

      // Dynamic content with revalidation
      // 'Cache-Control': 'public, max-age=0, s-maxage=60, stale-while-revalidate=300',

      // Private user data
      // 'Cache-Control': 'private, no-cache, no-store, must-revalidate',
    },
  });
}
```

## Reducing Layout Shift (CLS)

```html
<!-- Always set width and height on images -->
<img src="photo.jpg" alt="..." width="800" height="600" />

<!-- Use aspect-ratio for responsive containers -->
<style>
  .video-container {
    aspect-ratio: 16 / 9;
    width: 100%;
    background: #f3f4f6;
  }
</style>

<!-- Reserve space for dynamic content -->
<style>
  .ad-slot {
    min-height: 250px; /* Reserve space before ad loads */
  }
  .skeleton {
    min-height: 200px;
    animation: pulse 2s infinite;
  }
</style>

<!-- Prevent FOUT (Flash of Unstyled Text) -->
<style>
  @font-face {
    font-family: 'CustomFont';
    src: url('/fonts/custom.woff2') format('woff2');
    font-display: swap; /* or 'optional' for less CLS */
  }
</style>
```

## Reducing INP (Interaction to Next Paint)

```typescript
// Break up long tasks with scheduler.yield() or setTimeout
async function processLargeList(items: Item[]) {
  const CHUNK_SIZE = 50;
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    processChunk(chunk);

    // Yield to the browser between chunks
    if ('scheduler' in globalThis && 'yield' in scheduler) {
      await scheduler.yield();
    } else {
      await new Promise((resolve) => setTimeout(resolve, 0));
    }
  }
}

// Debounce expensive input handlers
import { useDebouncedCallback } from 'use-debounce';

function SearchInput() {
  const handleSearch = useDebouncedCallback((term: string) => {
    performSearch(term); // Expensive operation
  }, 300);

  return <input onChange={(e) => handleSearch(e.target.value)} />;
}

// Use CSS containment for complex layouts
// .card { contain: content; }
// .sidebar { contain: layout style; }
```

## Lighthouse Audit Checklist

```bash
# CLI audit
npx lighthouse https://mysite.com --output=html --view

# CI integration (Lighthouse CI)
npx lhci autorun --config=lighthouserc.json
```

```json
// lighthouserc.json
{
  "ci": {
    "collect": { "url": ["http://localhost:3000/", "http://localhost:3000/products"] },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["error", { "minScore": 0.95 }],
        "categories:best-practices": ["error", { "minScore": 0.9 }],
        "categories:seo": ["error", { "minScore": 0.9 }]
      }
    }
  }
}
```

## Do's

- Measure before optimizing (use Lighthouse, WebPageTest, CrUX)
- Set `priority` on LCP images and preload critical fonts
- Use `loading="lazy"` on below-the-fold images
- Set explicit width/height on all images and videos to prevent CLS
- Code-split at the route level and lazy-load heavy components
- Use modern image formats (AVIF > WebP > JPEG)
- Implement appropriate caching headers for all responses
- Use `font-display: swap` or `optional` for web fonts

## Don'ts

- Do not load unused JavaScript (audit with bundle analyzer)
- Do not block rendering with synchronous scripts in `<head>`
- Do not use layout-thrashing DOM reads/writes in loops
- Do not lazy-load above-the-fold content
- Do not import entire libraries when you need one function
- Do not ignore CLS from dynamically injected content (ads, banners)
- Do not use `document.write()`  -  it blocks parsing
- Do not serve uncompressed assets (enable gzip/brotli)

## Troubleshooting

| Problem | Cause | Solution |
|---------|-------|----------|
| High LCP | Large hero image, render-blocking CSS/JS | Optimize image, preload critical resources, inline critical CSS |
| High CLS | Images without dimensions, late-injected content | Set explicit sizes, reserve space with skeleton/placeholder |
| High INP | Long tasks blocking main thread | Break tasks with `scheduler.yield()`, use Web Workers |
| Large bundle | Importing entire libraries | Use tree-shakeable imports, analyze with bundle visualizer |
| Slow TTFB | No server caching, slow DB | Add CDN, implement server-side caching, optimize queries |
| Flash of unstyled text | Web font loading | Use `font-display: swap`, preload font files |
