Skip to content

Images, Fonts, and Media Optimization

advanced11 min read

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.

Mental Model

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

FormatCompressionTransparencyAnimationBrowser SupportBest For
AVIFBest (50% smaller than JPEG)YesYesChrome, Firefox, Safari 16.4+Photos, complex images
WebPGreat (30% smaller than JPEG)YesYesAll modern browsersUniversal fallback
JPEGGoodNoNoUniversalFallback for old browsers
PNGLosslessYesNoUniversalIcons, screenshots with text
SVGVectorYesYesUniversalIcons, 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 compression gains

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.

Common Trap

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 srcset at multiple breakpoints
  • Lazy loads by default (use priority for LCP images)
  • Prevents CLS by requiring width/height or fill
  • Generates blur placeholder for perceived performance
LCP images need priority

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;
}
ValueBehaviorBest For
swapImmediate fallback, swap to custom when readyBody text (avoid invisible text)
optionalBrief block (~100ms), use custom only if ready immediatelyNon-critical fonts
fallbackBrief block (~100ms), short swap period (~3s)Balance between flash and performance
blockInvisible text for up to 3s, then swapIcons fonts (avoid broken fallback)
autoBrowser 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:

  1. Hero image was a 2.4MB unoptimized JPEG
  2. No width/height — caused 300ms layout shift when loaded
  3. loading="lazy" on the hero image (the default in the framework)
  4. 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 doWhat 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
Quiz
Your LCP image is an <img> with loading='lazy' (the default in your framework). What is the first thing you should change?
Quiz
An image has CSS width: 100%; height: auto; but no HTML width/height attributes. What happens when it loads?
Key Rules
  1. 1Use AVIF as primary format with WebP fallback. AVIF is 50% smaller than JPEG at equivalent quality.
  2. 2Always include width and height attributes on images to prevent CLS.
  3. 3Never lazy-load the LCP image. Use loading='eager' and fetchpriority='high' instead.
  4. 4Use font-display: swap for body text. Use font-display: optional for non-critical fonts.
  5. 5Self-host fonts to eliminate cross-origin connection overhead. Use @fontsource or download directly.
  6. 6Size-adjust and metric overrides on @font-face prevent CLS from font swaps.