Images, Fonts, and Media Optimization
The Bytes That Matter Most
Here's a number that should stop you in your tracks: images account for roughly 50% of the average page's total bytes. Fonts add another 5-10%. Together, these media resources are the dominant factor in page load time, Largest Contentful Paint (LCP), and Cumulative Layout Shift (CLS).
Yet most developers treat image and font optimization as an afterthought — a production build step they never think about. That's a mistake. Understanding how the browser loads, decodes, and renders these resources reveals why certain patterns cause layout shifts and how to eliminate them.
Think of images and fonts as furniture being delivered to a house under construction. If the delivery arrives before the rooms are measured (no width/height attributes), workers have to stop construction, measure the furniture, resize the room, and push everything else around — that's a layout shift. If the wrong size furniture arrives (unoptimized format), it takes longer to carry in, blocking other deliveries. The goal: know the dimensions in advance, choose the smallest packaging, and schedule deliveries so critical items arrive first.
Image Formats: Choosing the Right One
Format Comparison
| Format | Compression | Transparency | Animation | Browser Support | Best For |
|---|---|---|---|---|---|
| AVIF | Best (50% smaller than JPEG) | Yes | Yes | Chrome, Firefox, Safari 16.4+ | Photos, complex images |
| WebP | Great (30% smaller than JPEG) | Yes | Yes | All modern browsers | Universal fallback |
| JPEG | Good | No | No | Universal | Fallback for old browsers |
| PNG | Lossless | Yes | No | Universal | Icons, screenshots with text |
| SVG | Vector | Yes | Yes | Universal | Icons, logos, illustrations |
Serving the Best Format
<!-- The <picture> element serves the best format each browser supports -->
<picture>
<!-- AVIF: smallest file, served to browsers that support it -->
<source srcset="/hero.avif" type="image/avif">
<!-- WebP: fallback for browsers without AVIF -->
<source srcset="/hero.webp" type="image/webp">
<!-- JPEG: universal fallback -->
<img src="/hero.jpg" alt="Hero image" width="1200" height="630">
</picture>
The browser evaluates <source> elements top to bottom and uses the first one it supports. The <img> is the final fallback.
AVIF achieves 50% smaller file sizes than JPEG at equivalent visual quality. A 200KB JPEG hero image becomes a 100KB AVIF. For a page with 10 images, that's 500KB+ in savings — a significant improvement on mobile networks.
Responsive Images with srcset
Think about this: why serve a 3000px image to a 375px phone? That's wasting 80% of the downloaded bytes. srcset lets the browser choose the right size:
<!-- Width-based srcset: browser picks the closest match to rendered width -->
<img
srcset="
/hero-400.avif 400w,
/hero-800.avif 800w,
/hero-1200.avif 1200w,
/hero-2400.avif 2400w
"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
src="/hero-1200.avif"
alt="Hero image"
width="1200"
height="630"
>
The sizes attribute tells the browser the rendered width at each breakpoint. The browser then picks the smallest srcset entry that covers that width at the device's pixel ratio.
For a 375px phone at 2x DPR with sizes="100vw":
- Rendered width: 375px
- Needed pixels: 375 * 2 = 750px
- Browser picks:
hero-800.avif(closest >= 750)
The DPR Gotcha
<!-- DPR-based srcset: for fixed-size images (icons, avatars) -->
<img
srcset="/avatar-48.avif 1x, /avatar-96.avif 2x, /avatar-144.avif 3x"
src="/avatar-96.avif"
alt="User avatar"
width="48"
height="48"
>
Preventing CLS from Images
This is one of the most common performance mistakes on the web. Images without width and height attributes cause layout shifts. When the image loads, the browser suddenly learns its dimensions and pushes surrounding content down.
<!-- BAD: No dimensions — causes CLS when image loads -->
<img src="/photo.avif" alt="Photo">
<!-- GOOD: Explicit dimensions — browser reserves space before image loads -->
<img src="/photo.avif" alt="Photo" width="800" height="600">
Modern CSS automatically uses these attributes for aspect ratio calculation:
/* Modern browsers use width/height attributes to calculate aspect ratio
before the image loads, preventing layout shift */
img {
max-width: 100%;
height: auto; /* Maintains aspect ratio while being responsive */
}
With width="800" and height="600", the browser knows the aspect ratio is 4:3 and reserves the correct space even before a single byte of the image downloads.
CSS width: 100%; height: auto; on an image WITHOUT HTML width and height attributes still causes CLS — the browser doesn't know the aspect ratio until the image headers download. Always include both HTML attributes AND the CSS responsive sizing. Alternatively, use the CSS aspect-ratio property directly.
next/image Patterns
Next.js <Image> component handles most image optimization automatically:
import Image from 'next/image';
// Automatically: AVIF/WebP, responsive srcset, lazy loading, blur placeholder
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority // Disables lazy loading for LCP image
sizes="(max-width: 768px) 100vw, 1200px"
/>
// Fill mode: for images with unknown dimensions
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
<Image
src="/dynamic-image.jpg"
alt="Dynamic content"
fill
sizes="(max-width: 768px) 100vw, 50vw"
style={{ objectFit: 'cover' }}
/>
</div>
Key next/image behaviors:
- Auto-generates AVIF and WebP variants
- Creates responsive
srcsetat multiple breakpoints - Lazy loads by default (use
priorityfor LCP images) - Prevents CLS by requiring
width/heightorfill - Generates blur placeholder for perceived performance
next/image lazy-loads by default. Your LCP image (usually the hero image) must have priority prop to disable lazy loading — otherwise the browser waits until the image scrolls near the viewport before downloading, dramatically hurting LCP.
Font Optimization
font-display Strategies
Fonts are trickier than most people realize. font-display controls what happens while a custom font is loading:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
}
| Value | Behavior | Best For |
|---|---|---|
swap | Immediate fallback, swap to custom when ready | Body text (avoid invisible text) |
optional | Brief block (~100ms), use custom only if ready immediately | Non-critical fonts |
fallback | Brief block (~100ms), short swap period (~3s) | Balance between flash and performance |
block | Invisible text for up to 3s, then swap | Icons fonts (avoid broken fallback) |
auto | Browser default (usually block) | Avoid — be explicit |
Preventing CLS from Fonts
Font swaps cause layout shifts when the custom font has different metrics than the fallback. Text reflows, pushing content around.
/* Match fallback font metrics to custom font to minimize reflow */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
/* size-adjust and ascent/descent-override match fallback metrics */
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
The size-adjust and metric override descriptors align the fallback font's dimensions with the custom font, so text occupies the same space regardless of which font is rendering.
Font Loading Best Practices
<head>
<!-- 1. Preconnect to font origin -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- 2. Preload the most critical font file -->
<link rel="preload" href="/fonts/inter-400.woff2" as="font"
type="font/woff2" crossorigin>
<!-- 3. Use font-display: swap in @font-face -->
<!-- 4. Self-host fonts when possible (avoids third-party connection) -->
</head>
Why self-hosting fonts beats Google Fonts
Google Fonts served from fonts.googleapis.com requires two connections: one to fonts.googleapis.com (CSS) and one to fonts.gstatic.com (font files). Even with preconnect, that's two DNS lookups, two TCP handshakes, and two TLS negotiations. Self-hosting eliminates both cross-origin connections — the font loads from your existing connection with zero additional latency. Modern tools like @fontsource make self-hosting trivial: npm install @fontsource/inter and import it.
Lazy Loading Images
<!-- Native lazy loading — built into the browser -->
<img src="/below-fold.avif" alt="Content" width="800" height="600" loading="lazy">
<!-- LCP image: NEVER lazy load -->
<img src="/hero.avif" alt="Hero" width="1200" height="630" loading="eager"
fetchpriority="high">
<!-- Combine with decoding="async" for non-critical images -->
<img src="/gallery.avif" alt="Gallery" width="600" height="400"
loading="lazy" decoding="async">
loading="lazy" tells the browser to defer downloading the image until it's near the viewport. The browser uses its own heuristics for "near" — typically a few viewport heights away.
decoding="async" tells the browser it can decode the image on a background thread without blocking the main thread. This prevents image decode from adding to main thread work during scroll.
Production Scenario: The 4-Second LCP
This one had everything wrong. An e-commerce product page had LCP of 4.2 seconds. The LCP element was the product hero image.
Root causes:
- Hero image was a 2.4MB unoptimized JPEG
- No
width/height— caused 300ms layout shift when loaded loading="lazy"on the hero image (the default in the framework)- No preload or fetchpriority hint
The fix:
<head>
<link rel="preload" href="/products/shoe-hero.avif" as="image"
fetchpriority="high">
</head>
<img
src="/products/shoe-hero.avif"
alt="Running shoe product photo"
width="1200"
height="900"
fetchpriority="high"
loading="eager"
decoding="async"
/>
Combined with converting to AVIF (2.4MB → 180KB) and proper sizing (serving 1200px instead of 4000px):
- LCP: 4.2s → 1.1s
- CLS: 0.18 → 0 (layout shift eliminated)
- Total page weight: -2.2MB
| What developers do | What they should do |
|---|---|
| Lazy load the LCP image Lazy loading defers download until near-viewport — exactly the opposite of what you want for the most important image | Use loading='eager' and fetchpriority='high' on the LCP image |
| Serve the same image size to all devices A 2400px image on a 375px phone wastes 80% of downloaded bytes. srcset lets the browser pick the right size. | Use srcset with multiple sizes and a sizes attribute |
| Omit width and height attributes on images Without dimensions, the browser can't reserve space — causing layout shifts (CLS) when the image loads | Always include width and height attributes for aspect ratio calculation |
| Use font-display: block for body text font-display: block hides text for up to 3 seconds while the font loads. Users see blank space instead of readable content. | Use font-display: swap for body text, optional for non-critical decorative fonts |
- 1Use AVIF as primary format with WebP fallback. AVIF is 50% smaller than JPEG at equivalent quality.
- 2Always include width and height attributes on images to prevent CLS.
- 3Never lazy-load the LCP image. Use loading='eager' and fetchpriority='high' instead.
- 4Use font-display: swap for body text. Use font-display: optional for non-critical fonts.
- 5Self-host fonts to eliminate cross-origin connection overhead. Use @fontsource or download directly.
- 6Size-adjust and metric overrides on @font-face prevent CLS from font swaps.