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.
| Format | Typical size vs JPEG | Transparency | Browser support |
|---|---|---|---|
| JPEG | baseline | No | Universal |
| PNG | larger | Yes | Universal |
| WebP | ~30% smaller | Yes | All modern browsers |
| AVIF | ~50% smaller | Yes | All 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/heightoraspect-ratioto 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+sizesso each device downloads only the resolution it needs. - Self-host subsetted
woff2variable fonts withfont-display: swapand preload the critical one. - Reach for your framework’s image component before hand-rolling, and compress assets in CI, not at runtime.