Skip to content
React rc performance 4 min read

Image & Asset Optimization

Images and fonts are usually the heaviest bytes a React app ships. A single unoptimized hero image can outweigh your entire JavaScript bundle, push out the Largest Contentful Paint, and trigger layout shifts as it loads. The good news is that asset optimization is mostly about applying a handful of browser-native features correctly: lazy loading, modern formats, responsive sizing, and disciplined font loading. This page covers the practical techniques that move the needle.

Lazy loading images

The browser can defer offscreen images natively with the loading attribute. Set loading="lazy" on anything below the fold so it only downloads as the user scrolls toward it, and loading="eager" (the default) on your above-the-fold hero.

function Gallery({ photos }) {
  return (
    <ul className="gallery">
      {photos.map((photo, i) => (
        <li key={photo.id}>
          <img
            src={photo.url}
            alt={photo.caption}
            width={400}
            height={300}
            loading={i === 0 ? "eager" : "lazy"}
            decoding="async"
          />
        </li>
      ))}
    </ul>
  );
}

Always set explicit width and height (or an aspect-ratio in CSS). The browser then reserves the correct box before the image arrives, eliminating Cumulative Layout Shift. decoding="async" lets the browser decode off the main thread so it doesn’t block rendering.

Never lazy-load your LCP image. Marking the hero loading="lazy" delays the most important paint and reliably hurts your Core Web Vitals scores.

Modern formats: WebP and AVIF

JPEG and PNG are decades old. WebP is typically 25-35% smaller at equal quality, and AVIF often beats WebP by another 20-30%. Serve the newest format the browser supports and fall back gracefully with <picture>.

function ProductImage({ name }) {
  return (
    <picture>
      <source srcSet={`/img/${name}.avif`} type="image/avif" />
      <source srcSet={`/img/${name}.webp`} type="image/webp" />
      <img
        src={`/img/${name}.jpg`}
        alt={name}
        width={600}
        height={600}
        loading="lazy"
      />
    </picture>
  );
}

The browser picks the first <source> whose type it understands; older browsers ignore the <source> tags and use the <img> fallback. Generate the formats at build time with a tool like sharp or vite-imagetools rather than shipping conversions to the client.

FormatTypical size vs JPEGTransparencyBrowser support
JPEGbaselineNoUniversal
PNGlargerYesUniversal
WebP~30% smallerYesAll modern browsers
AVIF~50% smallerYesAll modern browsers

Responsive images with srcSet

A 1600px image wasted on a 400px phone screen burns bandwidth. Use srcSet to offer multiple resolutions and sizes to tell the browser how wide the image renders, so it downloads the smallest file that still looks sharp.

function Hero() {
  return (
    <img
      src="/img/hero-800.jpg"
      srcSet="/img/hero-400.jpg 400w, /img/hero-800.jpg 800w, /img/hero-1600.jpg 1600w"
      sizes="(max-width: 600px) 100vw, 50vw"
      alt="Mountain landscape at sunrise"
      width={1600}
      height={900}
      fetchPriority="high"
    />
  );
}

The w descriptors describe each file’s intrinsic width; sizes describes the slot. Adding fetchPriority="high" to the LCP image tells the browser to fetch it ahead of lower-priority assets.

Framework image components

Meta-frameworks ship image components that automate formats, sizing, and lazy loading. If you use one, prefer it over hand-rolled <img> tags.

// Next.js
import Image from "next/image";

export default function Avatar() {
  return (
    <Image
      src="/avatar.png"
      alt="User avatar"
      width={96}
      height={96}
      priority={false}
    />
  );
}

Next.js <Image> generates srcSet, serves AVIF/WebP, and lazy-loads by default. In a plain Vite + React app, vite-imagetools gives you the same multi-format pipeline through import queries:

import heroSet from "./hero.jpg?w=400;800;1600&format=avif;webp&as=srcset";

Output:

heroSet => "/assets/hero-400.avif 400w, /assets/hero-800.avif 800w, ..."

Font loading

Web fonts block text rendering if mishandled. Use font-display: swap so text shows immediately in a fallback font and swaps when the custom font arrives, and preload the critical font file.

function Head() {
  return (
    <link
      rel="preload"
      href="/fonts/inter-var.woff2"
      as="font"
      type="font/woff2"
      crossOrigin="anonymous"
    />
  );
}
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var.woff2") format("woff2");
  font-display: swap;
}

Prefer self-hosted woff2 (the smallest, best-supported format) and variable fonts, which bundle every weight into one file. Subset fonts to the characters you actually use to shave kilobytes further.

Best Practices

  • Always set explicit width/height or aspect-ratio to prevent layout shift.
  • Lazy-load below-the-fold images; eagerly load and fetchPriority="high" the LCP image.
  • Serve AVIF with WebP and a JPEG fallback via <picture>, generated at build time.
  • Use srcSet + sizes so each device downloads only the resolution it needs.
  • Self-host subsetted woff2 variable fonts with font-display: swap and preload the critical one.
  • Reach for your framework’s image component before hand-rolling, and compress assets in CI, not at runtime.
Last updated June 14, 2026
Was this helpful?