Web Performance Tips

Core Web Vitals, lazy loading, image optimization, and caching strategies I keep coming back to.

Core Web Vitals

LCP — Largest Contentful Paint

Measures the time until the largest visible element (image, heading, text block) finishes rendering. Target: under 2.5 seconds.

Common culprits for a slow LCP: render-blocking CSS/JS, slow server response (TTFB), unoptimized hero images, and client-side rendering delays.

INP — Interaction to Next Paint

Replaced FID in March 2024. INP measures the latency of all interactions during a page visit and reports the worst (or near-worst) one. Target: under 200 ms.

To improve INP: break up long tasks with setTimeout or scheduler.yield(), reduce main-thread JS, and avoid layout thrashing.

CLS — Cumulative Layout Shift

Tracks unexpected layout shifts during the page lifecycle. Target: under 0.1. Always set explicit width and height on images/videos, reserve space for ads/embeds, and avoid injecting content above the fold after load.

Measuring

Use any of these to audit your vitals:

import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP(console.log);
onINP(console.log);
onCLS(console.log);

Images

Modern formats: WebP & AVIF

WebP is ~25-30% smaller than JPEG at comparable quality. AVIF pushes that to ~50% but encoding is slower. Use the <picture> element to serve the best format each browser supports:

<picture>
  <source srcset="hero.avif" type="image/avif">
  <source srcset="hero.webp" type="image/webp">
  <img src="hero.jpg" alt="Hero banner"
       width="1200" height="600"
       loading="lazy">
</picture>

Lazy loading

Add loading="lazy" to any image below the fold. Do not lazy-load the LCP image — that hurts your LCP score. For the hero image, use fetchpriority="high" instead:

<!-- Hero / LCP image: load eagerly with high priority -->
<img src="hero.webp" alt="Hero" fetchpriority="high"
     width="1200" height="600">

<!-- Below-the-fold images: lazy load -->
<img src="card.webp" alt="Card" loading="lazy"
     width="400" height="300">

Responsive images with srcset

Let the browser pick the right resolution for the viewport:

<img srcset="photo-400.webp 400w,
             photo-800.webp 800w,
             photo-1200.webp 1200w"
     sizes="(max-width: 600px) 100vw,
            (max-width: 1000px) 50vw,
            33vw"
     src="photo-800.webp"
     alt="Responsive photo"
     width="1200" height="800">

Preventing CLS with width/height

Always include width and height attributes so the browser can calculate the aspect ratio before the image loads. Combine with CSS:

img {
  max-width: 100%;
  height: auto;
}

Caching

Cache-Control headers

The most important HTTP caching header. Common directives:

# Nginx example
location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

location / {
    add_header Cache-Control "no-cache";
    add_header ETag "";
}

ETags

ETags let the server return 304 Not Modified when the resource hasn't changed, saving bandwidth. Most servers generate them automatically. Pair with Cache-Control: no-cache for HTML documents.

Service Worker caching

A service worker gives you fine-grained control over cache strategies. Common patterns:

// Stale-while-revalidate strategy
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('v1').then(async (cache) => {
      const cached = await cache.match(event.request);
      const fetched = fetch(event.request).then((response) => {
        cache.put(event.request, response.clone());
        return response;
      });
      return cached || fetched;
    })
  );
});

CDN cache tips

Put your CDN in front and use content hashing in filenames so you can cache forever. Set short TTLs (or s-maxage) on HTML so the CDN revalidates frequently while static assets stay cached.

# Different TTLs for CDN vs browser
Cache-Control: public, max-age=60, s-maxage=3600

Fonts

font-display: swap

Prevents invisible text (FOIT) by showing a fallback font immediately, then swapping when the web font loads. This is almost always what you want:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}

Preloading critical fonts

Preload the one or two fonts used above the fold so the browser fetches them early:

<link rel="preload" href="/fonts/inter-var.woff2"
      as="font" type="font/woff2" crossorigin>

Subsetting

If you only need Latin characters, subset the font to drop unused glyphs. Tools like glyphhanger or fonttools can reduce a 200 KB font to under 20 KB.

# Subset with fonttools / pyftsubset
pyftsubset Inter.ttf \
  --output-file=Inter-latin.woff2 \
  --flavor=woff2 \
  --layout-features='kern,liga' \
  --unicodes=U+0000-007F,U+00A0-00FF

FOUT vs FOIT

FOUT is usually the better trade-off. Minimize the swap disruption by choosing a fallback with similar metrics:

/* Adjusted fallback to reduce layout shift on swap */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
  size-adjust: 107%;
}

System font stack

Skip web fonts entirely for maximum speed. A modern system font stack:

body {
  font-family: system-ui, -apple-system, BlinkMacSystemFont,
    'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}

Bundle Optimization

Code splitting with dynamic import()

Split your bundle so users only download the code they need. Use dynamic import() to create separate chunks:

// Load a heavy module only when the user needs it
button.addEventListener('click', async () => {
  const { renderChart } = await import('./chart.js');
  renderChart(data);
});

Lazy loading routes (React example)

Each route becomes its own chunk, loaded on navigation:

import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Tree shaking

Tree shaking removes unused exports from your final bundle. It works automatically in webpack (production mode) and Rollup/Vite, but only with ES module syntax (import/export).

Tips to make tree shaking effective:

// package.json
{
  "name": "my-lib",
  "sideEffects": false
}

// Or mark specific files that DO have side effects:
{
  "sideEffects": ["./src/polyfills.js", "*.css"]
}

Analyzing your bundle

Use webpack-bundle-analyzer to visualize what's in your bundle and find heavy dependencies:

npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',   // generates an HTML report
      openAnalyzer: false,
    }),
  ],
};

For Vite projects, use rollup-plugin-visualizer instead:

// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';

export default {
  plugins: [
    visualizer({ open: true }),
  ],
};