webdev.complete
Performance & Core Web Vitals
🚦Going to Production
Lesson 107 of 117
25 min

Performance Techniques

Image formats, font loading, code splitting, caching.

Knowing what to measure is half the battle. The other half is the toolbox of fixes you reach for once the dashboard turns red. There's no single magic technique here; modern web perf is a stack of small, deliberate choices. We'll walk through the highest-leverage ones: image formats, lazy loading, fonts, code splitting, and the React Compiler's newer auto-memoization.

Images: the easiest wins

Images are usually the biggest bytes on any page. Three things matter: format, dimensions, and loading priority.

Pick the right format

  • AVIF - best compression, ~50% smaller than JPEG at comparable quality. Supported in all evergreen browsers since 2024. Slower to encode.
  • WebP - older modern format. ~30% smaller than JPEG. Universal support. Safe default.
  • JPEG - photos. Use only when you must.
  • PNG - graphics with hard edges or transparency. Lossless, so it gets big fast.
  • SVG - icons and logos. Vector, scales for free.

Serve the smallest the browser can decode. The <picture> element makes this clean:

html
<picture>
  <source srcset="/hero.avif" type="image/avif">
  <source srcset="/hero.webp" type="image/webp">
  <img src="/hero.jpg" alt="" width="1200" height="630">
</picture>

Responsive images with srcset + sizes

Don't ship a 2400px hero to a 360px phone. srcset offers multiple sizes; sizes tells the browser which slot the image fills so it can pick.

html
<img
  src="/hero-800.jpg"
  srcset="/hero-400.jpg 400w,
          /hero-800.jpg 800w,
          /hero-1600.jpg 1600w"
  sizes="(max-width: 600px) 100vw, 800px"
  alt=""
  width="800" height="450"
>

Read this slowly: srcset lists candidate files with their natural widths in w. sizes says "at viewports up to 600px the image fills 100% of the viewport; above that, it's 800px wide." The browser does the math and picks the smallest file that's big enough.

Lazy loading and fetchpriority

html
<!-- LCP image: load it eagerly with high priority -->
<img src="/hero.avif" alt="" fetchpriority="high" width="1200" height="630">

<!-- Below-the-fold image: lazy-load it -->
<img src="/thumb.avif" alt="" loading="lazy" width="400" height="300">
Don't lazy-load the LCP image
loading="lazy" on the hero is one of the most common perf regressions. The browser delays the request, LCP goes up. Mark the LCP image eager (the default) with fetchpriority="high".

Fonts: the FOUT/FOIT trap

Custom fonts come from the server. Until they arrive, the browser has to decide: show fallback text now and swap when the font loads (FOUT - Flash of Unstyled Text), or hide the text until the font arrives (FOIT - Flash of Invisible Text). FOIT is worse: blank space costs you LCP. Always pick FOUT with font-display: swap:

css
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;     /* show fallback immediately, swap when ready */
}

/* Reduce CLS during swap by matching the fallback's metrics */
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  ascent-override: 90%;
  descent-override: 22.5%;
  size-adjust: 107%;
}

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

Even better: preload the font file so it starts loading in parallel with the HTML:

html
<link
  rel="preload"
  href="/fonts/inter.woff2"
  as="font"
  type="font/woff2"
  crossorigin
>
next/font does this automatically
Next.js' next/font self-hosts Google Fonts, sets font-display: swap, preloads the file, and generates a matched fallback in one line. If you're on Next, just use it.

Code splitting with dynamic import

Don't ship the entire app on the home page. Split large or rarely used modules out into separate chunks loaded on demand.

tsx
// Static import: everything in one bundle
import Chart from "./Chart";

// Dynamic import: Chart gets its own chunk, loaded only when rendered
const Chart = lazy(() => import("./Chart"));

function Dashboard() {
  return (
    <Suspense fallback={<Spinner />}>
      <Chart />
    </Suspense>
  );
}

Bundlers (Vite, Webpack, Turbopack) see the dynamic import() and emit a separate file. The browser fetches it on first render of <Chart />, not on initial page load.

Route-based splitting

Modern frameworks do this for free. In Next.js App Router, every page is automatically its own chunk; visiting /settings doesn't download /dashboard's code. In SPAs with React Router, wrap your routes:

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

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

<Suspense fallback={<PageLoader />}>
  <Routes>
    <Route path="/dashboard" element={<Dashboard />} />
    <Route path="/settings" element={<Settings />} />
  </Routes>
</Suspense>

The React Compiler: stop hand-memoizing

React 19 ships with an optional compiler that analyzes your component and inserts memoization automatically. The result: you can delete almost every useMemo, useCallback, and React.memo in the codebase.

Before - hand-memoized
function ProductList({ products, filter }) {
  const filtered = useMemo(
    () => products.filter((p) => p.name.includes(filter)),
    [products, filter],
  );

  const onClick = useCallback((id) => navigate(`/p/${id}`), []);

  return <List items={filtered} onClick={onClick} />;
}
After - let the compiler do it
function ProductList({ products, filter }) {
  const filtered = products.filter((p) => p.name.includes(filter));
  const onClick = (id) => navigate(`/p/${id}`);
  return <List items={filtered} onClick={onClick} />;
}

Enable it in your babel.config.js or as a Vite plugin:

babel.config.js
module.exports = {
  plugins: [
    ["babel-plugin-react-compiler", { /* options */ }],
  ],
};
It only helps if you follow the rules
The compiler refuses to memoize components that break the Rules of React (mutating props, side effects in render, etc.). Run the ESLint plugin eslint-plugin-react-compiler to see which components it skipped and why.

The 80/20 perf checklist

  1. Serve AVIF/WebP with srcset + sizes.
  2. Set width and height on every image.
  3. Lazy-load below-fold, eager-load + fetchpriority="high" on the LCP image.
  4. Self-host fonts with font-display: swap and rel="preload".
  5. Split routes and heavy components with lazy + Suspense.
  6. Adopt the React Compiler. Delete manual memos.
  7. Audit with Lighthouse, then verify in field data (CrUX).

Quiz

Quiz1 / 4

Which image format gives the best compression for photos, supported across modern browsers?

Recap

  • Image format priority: AVIF > WebP > JPEG. Use <picture> with fallback.
  • Always set width/height. Use srcset + sizes for responsive images.
  • loading="lazy" below the fold, fetchpriority="high" on the LCP image.
  • Fonts: self-host, font-display: swap, preload, match fallback metrics.
  • Code-split with dynamic import() + Suspense. Frameworks split routes for free.
  • Adopt the React Compiler. Stop hand-writing useMemo.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.