← All articles
PERFORMANCE Web Performance Optimization: Core Web Vitals and Be... 2026-02-09 · 9 min read · performance · core-web-vitals · bundle-size

Web Performance Optimization: Core Web Vitals and Beyond

Performance 2026-02-09 · 9 min read performance core-web-vitals bundle-size lazy-loading caching lighthouse

Web Performance Optimization: Core Web Vitals and Beyond

Performance isn't just a technical metric -- it directly affects revenue. Amazon found that every 100ms of latency costs 1% in sales. Google uses Core Web Vitals as a ranking signal. Users abandon pages that take more than 3 seconds to load. The good news: most performance wins come from a handful of well-understood techniques.

Core Web Vitals: The Metrics That Matter

Google's Core Web Vitals are the industry standard for measuring user-perceived performance. There are three metrics, and they measure different aspects of the experience:

Metric What It Measures Good Needs Improvement Poor
LCP (Largest Contentful Paint) Loading speed < 2.5s 2.5s - 4.0s > 4.0s
INP (Interaction to Next Paint) Responsiveness < 200ms 200ms - 500ms > 500ms
CLS (Cumulative Layout Shift) Visual stability < 0.1 0.1 - 0.25 > 0.25

Measuring Core Web Vitals

// Using the web-vitals library (Google's official library)
import { onLCP, onINP, onCLS } from "web-vitals";

function sendToAnalytics(metric: { name: string; value: number; id: string }) {
  // Send to your analytics endpoint
  navigator.sendBeacon("/api/vitals", JSON.stringify(metric));
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);

Lab tools (synthetic testing):

Field tools (real user monitoring):

Lab data is useful for debugging, but field data is what Google uses for ranking and what reflects actual user experience.

Bundle Analysis: Finding the Fat

Before optimizing anything, measure your JavaScript bundle size. Most performance problems are caused by shipping too much JavaScript.

Bundle Analysis Tools

# For Vite projects
npx vite-bundle-visualizer

# For Webpack projects
npx webpack-bundle-analyzer stats.json

# For Next.js
ANALYZE=true next build  # Requires @next/bundle-analyzer

# Generic tool -- works with any bundler
npx source-map-explorer dist/**/*.js

Common Bundle Size Offenders

Library Size (minified + gzipped) Lighter Alternative
moment.js 72 KB date-fns (tree-shakeable) or dayjs (2 KB)
lodash (full) 72 KB lodash-es (tree-shakeable) or native JS
chart.js 65 KB lightweight-charts (40 KB)
Material UI (full import) 100+ KB Individual imports + tree shaking
axios 13 KB Native fetch (0 KB)
// BAD -- imports everything
import _ from "lodash";
_.debounce(fn, 300);

// GOOD -- tree-shakeable import
import { debounce } from "lodash-es";
debounce(fn, 300);

// BEST -- native implementation (no dependency)
function debounce<T extends (...args: unknown[]) => unknown>(fn: T, ms: number) {
  let timer: ReturnType<typeof setTimeout>;
  return (...args: Parameters<T>) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

Tree Shaking Verification

Tree shaking only works with ES modules. Verify your imports are actually being eliminated:

# Check if a dependency supports tree shaking
# Look for "module" or "exports" field in package.json
cat node_modules/lodash-es/package.json | jq '.module, .exports'

# Build and check output size
vite build
ls -la dist/assets/*.js

Lazy Loading and Code Splitting

Don't load code until it's needed. This is the single biggest performance win for most applications.

Route-Based Code Splitting (React)

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

// Each route becomes its own chunk
const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));

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

Component-Level Lazy Loading

import { lazy, Suspense, useState } from "react";

// Heavy components loaded on demand
const MarkdownEditor = lazy(() => import("./components/MarkdownEditor"));
const ChartWidget = lazy(() => import("./components/ChartWidget"));

function Dashboard() {
  const [showEditor, setShowEditor] = useState(false);

  return (
    <div>
      <button onClick={() => setShowEditor(true)}>Open Editor</button>
      {showEditor && (
        <Suspense fallback={<div>Loading editor...</div>}>
          <MarkdownEditor />
        </Suspense>
      )}
    </div>
  );
}

Prefetching for Perceived Performance

// Prefetch a route when the user hovers over a link
function PrefetchLink({ to, children }: { to: string; children: React.ReactNode }) {
  const prefetch = () => {
    // Dynamic import starts loading the chunk
    if (to === "/dashboard") import("./pages/Dashboard");
    if (to === "/settings") import("./pages/Settings");
  };

  return (
    <Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
      {children}
    </Link>
  );
}

Image Lazy Loading

<!-- Native lazy loading -- no JavaScript needed -->
<img src="photo.jpg" alt="Description" loading="lazy" width="800" height="600" />

<!-- With srcset for responsive images -->
<img
  srcset="photo-400.webp 400w, photo-800.webp 800w, photo-1200.webp 1200w"
  sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
  src="photo-800.webp"
  alt="Description"
  loading="lazy"
  width="800"
  height="600"
/>

Image Optimization

Images are typically the largest assets on a page. Optimizing them is high-impact and relatively straightforward.

Modern Image Formats

Format Compression Browser Support Best For
WebP 25-34% smaller than JPEG 97%+ Photos, general use
AVIF 50% smaller than JPEG 92%+ Photos (where supported)
SVG Vector (scales infinitely) 99%+ Icons, logos, illustrations
PNG Lossless 99%+ Screenshots, transparency

Automated Image Optimization

// vite.config.ts with vite-plugin-imagemin
import imagemin from "vite-plugin-imagemin";

export default defineConfig({
  plugins: [
    imagemin({
      gifsicle: { optimizationLevel: 3 },
      mozjpeg: { quality: 80 },
      pngquant: { quality: [0.8, 0.9] },
      webp: { quality: 80 },
      avif: { quality: 65 },
    }),
  ],
});

The Picture Element for Format Negotiation

<picture>
  <!-- Browser picks the first format it supports -->
  <source srcset="hero.avif" type="image/avif" />
  <source srcset="hero.webp" type="image/webp" />
  <img src="hero.jpg" alt="Hero image" width="1200" height="600" />
</picture>

Using Next.js Image Component

import Image from "next/image";

// Automatic optimization, lazy loading, and format selection
<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={600}
  priority // For above-the-fold images (disables lazy loading)
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

Caching Strategies

Caching is the most effective performance optimization. A cached response has zero latency.

HTTP Cache Headers

# Static assets with content hashing (cache forever)
Cache-Control: public, max-age=31536000, immutable
# Used for: app.a1b2c3.js, style.d4e5f6.css

# HTML pages (always revalidate)
Cache-Control: no-cache
# "no-cache" means "always check with server" (not "don't cache")

# API responses (cache briefly)
Cache-Control: private, max-age=60, stale-while-revalidate=300
# Serve cached version for 60s, then revalidate in background for up to 300s

# Never cache (sensitive data)
Cache-Control: no-store

Service Worker Caching

// sw.js -- Workbox-based service worker
import { precacheAndRoute } from "workbox-precaching";
import { registerRoute } from "workbox-routing";
import {
  CacheFirst,
  StaleWhileRevalidate,
  NetworkFirst,
} from "workbox-strategies";

// Precache app shell
precacheAndRoute(self.__WB_MANIFEST);

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

// Cache API responses with stale-while-revalidate
registerRoute(
  ({ url }) => url.pathname.startsWith("/api/"),
  new StaleWhileRevalidate({
    cacheName: "api-cache",
    plugins: [
      new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 }),
    ],
  })
);

// Cache pages with network-first strategy
registerRoute(
  ({ request }) => request.mode === "navigate",
  new NetworkFirst({
    cacheName: "pages",
    plugins: [
      new ExpirationPlugin({ maxEntries: 25 }),
    ],
  })
);

Caching Strategy Decision Tree

Is the asset immutable (content-hashed filename)?
  → Yes: Cache forever (max-age=31536000, immutable)
  → No:
    Is it HTML?
      → Yes: no-cache (always revalidate)
    Is it an API response?
      → Is the data sensitive?
        → Yes: no-store
        → No: stale-while-revalidate for read-heavy, no-cache for write-heavy
    Is it a font or third-party asset?
      → Yes: Cache for a long time but not immutable (max-age=604800)

Critical Rendering Path Optimization

The critical rendering path is the sequence of steps the browser takes to render the first paint. Optimizing it directly improves LCP.

Eliminate Render-Blocking Resources

<!-- BAD -- blocks rendering -->
<link rel="stylesheet" href="all-styles.css" />
<script src="analytics.js"></script>

<!-- GOOD -- inline critical CSS, defer non-critical -->
<style>
  /* Critical CSS: above-the-fold styles only */
  body { margin: 0; font-family: system-ui; }
  .hero { height: 60vh; display: flex; align-items: center; }
</style>

<!-- Load full CSS asynchronously -->
<link rel="preload" href="styles.css" as="style" onload="this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="styles.css" /></noscript>

<!-- Defer non-critical scripts -->
<script src="analytics.js" defer></script>

Preconnect and Preload

<head>
  <!-- Preconnect to critical third-party origins -->
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://cdn.example.com" crossorigin />

  <!-- DNS prefetch for less critical origins -->
  <link rel="dns-prefetch" href="https://analytics.example.com" />

  <!-- Preload critical resources -->
  <link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />
  <link rel="preload" href="/hero-image.webp" as="image" />
</head>

Font Loading Optimization

/* Use font-display to prevent invisible text */
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var.woff2") format("woff2");
  font-display: swap; /* Show fallback font immediately, swap when loaded */
  font-weight: 100 900;
}

/* Size-adjust to minimize layout shift from font swap */
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}

JavaScript Performance

Avoiding Layout Thrashing

// BAD -- reads and writes interleaved (forces multiple reflows)
elements.forEach((el) => {
  const height = el.offsetHeight; // Read (forces layout)
  el.style.height = height * 2 + "px"; // Write (invalidates layout)
});

// GOOD -- batch reads, then batch writes
const heights = elements.map((el) => el.offsetHeight); // All reads
elements.forEach((el, i) => {
  el.style.height = heights[i] * 2 + "px"; // All writes
});

// BEST -- use requestAnimationFrame for DOM writes
function updateLayout() {
  const heights = elements.map((el) => el.offsetHeight);
  requestAnimationFrame(() => {
    elements.forEach((el, i) => {
      el.style.height = heights[i] * 2 + "px";
    });
  });
}

Virtualization for Long Lists

// Using @tanstack/react-virtual for large lists
import { useVirtualizer } from "@tanstack/react-virtual";

function VirtualList({ items }: { items: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50, // Estimated row height
    overscan: 5, // Render 5 items outside the viewport
  });

  return (
    <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            {items[virtualItem.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

Web Workers for Heavy Computation

// worker.ts
self.addEventListener("message", (event) => {
  const { data, type } = event.data;

  if (type === "PROCESS_CSV") {
    // Heavy computation off the main thread
    const result = parseAndAnalyzeCSV(data);
    self.postMessage({ type: "RESULT", result });
  }
});

// main.ts
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
  type: "module",
});

worker.postMessage({ type: "PROCESS_CSV", data: csvString });
worker.addEventListener("message", (event) => {
  if (event.data.type === "RESULT") {
    setAnalysis(event.data.result);
  }
});

Performance Budget

Set budgets and enforce them in CI. Without enforcement, performance degrades over time.

// bundlesize configuration in package.json
{
  "bundlesize": [
    { "path": "dist/assets/*.js", "maxSize": "200 kB" },
    { "path": "dist/assets/*.css", "maxSize": "50 kB" }
  ]
}
# Lighthouse CI in GitHub Actions
name: Performance Budget
on: [pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci && npm run build
      - name: Lighthouse CI
        uses: treosh/lighthouse-ci-action@v11
        with:
          configPath: ./lighthouserc.json
          uploadArtifacts: true
// lighthouserc.json
{
  "ci": {
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "first-contentful-paint": ["warn", { "maxNumericValue": 1500 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-byte-weight": ["warn", { "maxNumericValue": 500000 }]
      }
    }
  }
}

Quick Wins Checklist

If you're looking for the highest-impact changes with the least effort:

  1. Enable compression: Ensure your server sends gzip or Brotli-compressed responses
  2. Optimize images: Convert to WebP/AVIF, serve responsive sizes, lazy load below-the-fold images
  3. Remove unused JavaScript: Run your bundle analyzer, cut unnecessary dependencies
  4. Add proper cache headers: Content-hashed assets cached forever, HTML always revalidated
  5. Preconnect to critical origins: Fonts, CDNs, API servers
  6. Set explicit width/height on images: Prevents CLS
  7. Use loading="lazy" on below-the-fold images: Native browser support, zero JavaScript
  8. Defer non-critical JavaScript: Use defer or async attributes on script tags
  9. Use a CDN: Put your static assets on a CDN (Cloudflare, CloudFront, Fastly)
  10. Compress fonts: Use woff2 format, subset to only the characters you need

Summary

Web performance is about making deliberate choices, not applying every optimization blindly. Start by measuring with Core Web Vitals, identify your biggest bottleneck (usually JavaScript bundle size or unoptimized images), fix that first, and set up budgets to prevent regression. The tools are mature, the techniques are well-understood, and the impact on user experience and business metrics is real.