Skip to content

Image Optimization for Web Performance

A practical guide to image optimization for the web: modern formats, responsive images, lazy loading, CDN delivery and the real Core Web Vitals impact.

4 min read
Before and after comparison showing image file size reduction from 2MB to 40KB with visual quality preserved

Images account for the majority of transferred bytes on most web pages. An unoptimized hero image can single-handedly destroy your Largest Contentful Paint score. The good news: image optimization is one of the highest-impact, lowest-effort performance improvements you can make. Modern formats, responsive sizing, and lazy loading can reduce image payload by 70-90% without visible quality loss.

The goal is not to make images as small as possible. It is to deliver the right image, at the right size, in the right format, at the right time.

Modern Image Formats

WebP and AVIF offer dramatically better compression than JPEG and PNG while maintaining visual quality. Browser support is now broad enough to use them as the primary format.

htmlhtml
<!-- ❌ Serving a single JPEG format to all browsers -->
<img src="/photos/hero.jpg" alt="Product showcase" />
<!-- 2.1 MB — unoptimized, one size for all screens -->
 
<!-- ✅ Serving modern formats with fallback -->
<picture>
  <!-- AVIF: smallest file size, 94% browser support (2024) -->
  <source srcset="/photos/hero.avif" type="image/avif" />
  <!-- WebP: good compression, 97% browser support -->
  <source srcset="/photos/hero.webp" type="image/webp" />
  <!-- JPEG: universal fallback -->
  <img src="/photos/hero.jpg" alt="Product showcase"
       width="1200" height="600"
       loading="eager"
       fetchpriority="high" />
</picture>
<!-- AVIF: ~180 KB, WebP: ~280 KB, JPEG: ~2.1 MB -->
<!-- Same visual quality, 87% smaller with AVIF -->
shbash
# Convert images to modern formats using sharp-cli
# Install: npm install -g sharp-cli
 
# JPEG → WebP (quality 80 is visually lossless for photos)
sharp -i hero.jpg -o hero.webp --format webp --quality 80
 
# JPEG → AVIF (quality 60 is comparable to WebP quality 80)
sharp -i hero.jpg -o hero.avif --format avif --quality 60
 
# PNG → WebP (for images with transparency)
sharp -i logo.png -o logo.webp --format webp --quality 90
 
# Batch convert all JPEGs in a directory
for f in *.jpg; do
  sharp -i "$f" -o "${f%.jpg}.webp" --format webp --quality 80
  sharp -i "$f" -o "${f%.jpg}.avif" --format avif --quality 60
done

Responsive Images with srcset

Serving a 2000px-wide image to a 400px-wide mobile screen wastes bandwidth. Responsive images let the browser choose the appropriate size based on screen width and pixel density.

htmlhtml
<!-- ❌ One image size for all devices -->
<img src="/photos/hero-2000w.jpg" alt="Product showcase" />
<!-- Mobile downloads 2000px image, displays at 400px — wasteful -->
 
<!-- ✅ Multiple sizes — browser picks the best one -->
<img
  srcset="
    /photos/hero-400w.webp   400w,
    /photos/hero-800w.webp   800w,
    /photos/hero-1200w.webp 1200w,
    /photos/hero-2000w.webp 2000w
  "
  sizes="
    (max-width: 640px) 100vw,
    (max-width: 1024px) 80vw,
    60vw
  "
  src="/photos/hero-1200w.webp"
  alt="Product showcase"
  width="1200"
  height="600"
/>
<!-- Mobile downloads 400w (50KB), desktop downloads 2000w (280KB) -->
tstypescript
// Build script to generate responsive image variants
import sharp from 'sharp';
import { readdir } from 'fs/promises';
import { join, parse } from 'path';
 
const WIDTHS = [400, 800, 1200, 2000];
const FORMATS: Array<{ ext: string; options: object }> = [
  { ext: 'avif', options: { quality: 60 } },
  { ext: 'webp', options: { quality: 80 } },
];
 
async function generateResponsiveImages(inputDir: string, outputDir: string) {
  const files = await readdir(inputDir);
 
  for (const file of files) {
    const { name } = parse(file);
    const inputPath = join(inputDir, file);
 
    for (const width of WIDTHS) {
      for (const format of FORMATS) {
        const outputPath = join(outputDir, `${name}-${width}w.${format.ext}`);
 
        await sharp(inputPath)
          .resize(width, null, { withoutEnlargement: true })
          .toFormat(format.ext as keyof sharp.FormatEnum, format.options)
          .toFile(outputPath);
 
        console.log(`Generated: ${outputPath}`);
      }
    }
  }
}
 
generateResponsiveImages('./originals', './public/images');

Lazy Loading and Priority Hints

Not all images should load immediately. Above-the-fold images need eager loading with high priority. Below-the-fold images should lazy load — the browser fetches them only when they approach the viewport.

htmlhtml
<!-- Above the fold: load immediately with high priority -->
<img
  src="/photos/hero.webp"
  alt="Hero banner"
  width="1200" height="600"
  loading="eager"
  fetchpriority="high"
  decoding="async"
/>
 
<!-- Below the fold: lazy load when approaching viewport -->
<img
  src="/photos/feature-1.webp"
  alt="Feature showcase"
  width="600" height="400"
  loading="lazy"
  decoding="async"
/>
tstypescript
// ❌ Lazy loading ALL images — including the hero
// The hero image is the Largest Contentful Paint element
// Lazy loading it DELAYS LCP, making performance worse
<img src="/hero.webp" loading="lazy" /> // Don't do this
 
// ✅ Eager load above-the-fold, lazy load everything else
// Rule of thumb: the first 1-2 images should be eager
// Everything below the initial viewport should be lazy
htmlhtml
<!-- Preload critical hero image for fastest LCP -->
<head>
  <link
    rel="preload"
    as="image"
    href="/photos/hero.avif"
    type="image/avif"
    fetchpriority="high"
  />
</head>
<!-- This tells the browser to fetch the hero image immediately,
     before it even parses the <img> tag in the body -->

Preventing Layout Shift

Images without explicit dimensions cause layout shift — content jumps as images load. This destroys CLS (Cumulative Layout Shift) scores.

htmlhtml
<!-- ❌ No dimensions — causes layout shift as image loads -->
<img src="/photos/product.webp" alt="Product" />
<!-- Browser doesn't know the size until image loads → content jumps -->
 
<!-- ✅ Explicit width and height — browser reserves space -->
<img
  src="/photos/product.webp"
  alt="Product"
  width="600"
  height="400"
  style="max-width: 100%; height: auto;"
/>
<!-- Browser calculates aspect ratio from width/height -->
<!-- Reserves exact space before image loads → zero layout shift -->
csscss
/* Modern approach: aspect-ratio property */
.product-image {
  width: 100%;
  aspect-ratio: 3 / 2;  /* Maintains ratio without height attribute */
  object-fit: cover;     /* Fills container without distortion */
}
 
/* Placeholder blur while loading */
.image-container {
  position: relative;
  background-color: #e2e8f0;
  background-image: url('data:image/svg+xml,...'); /* Tiny blur placeholder */
  background-size: cover;
}
 
.image-container img {
  width: 100%;
  height: auto;
  transition: opacity 0.3s;
}
 
.image-container img[loading] {
  opacity: 0;
}

Measuring Impact with Core Web Vitals

Image optimization directly impacts two Core Web Vitals: LCP (Largest Contentful Paint) and CLS (Cumulative Layout Shift). Measure before and after to quantify the improvement.

tstypescript
// Measure LCP in the browser
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log('LCP:', entry.startTime, 'ms');
    console.log('Element:', (entry as any).element?.tagName);
    // If this is an <img>, your image optimization directly affects this number
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });
 
// Measure CLS contributions
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (!(entry as any).hadRecentInput) {
      console.log('Layout shift:', (entry as any).value);
      for (const source of (entry as any).sources ?? []) {
        console.log('Shifted element:', source.node?.tagName);
        // Images without width/height appear here
      }
    }
  }
}).observe({ type: 'layout-shift', buffered: true });
ymlyaml
# Before and after optimization — real numbers
before_optimization:
  hero_image_size: "2.1 MB (JPEG, 3000x1500)"
  total_image_payload: "8.4 MB"
  lcp: "4.2 seconds"
  cls: "0.28"
 
after_optimization:
  hero_image_size: "180 KB (AVIF, responsive srcset)"
  total_image_payload: "1.2 MB"
  lcp: "1.8 seconds"      # 57% improvement
  cls: "0.02"              # 93% improvement
 
changes_made:
  - "Converted to AVIF/WebP with picture element"
  - "Generated 4 responsive sizes per image"
  - "Added width/height to all img tags"
  - "Lazy loaded below-fold images"
  - "Preloaded hero image in document head"

Key Takeaways

  1. Use AVIF as primary format, WebP as fallback — AVIF delivers 50% smaller files than JPEG at similar quality, with 94% browser support
  2. Generate responsive image variants at 400w, 800w, 1200w, and 2000w — let the browser pick based on screen size
  3. Eager load above-the-fold images, lazy load everything else — and preload the LCP image in the document head
  4. Always set width and height attributes on images to prevent layout shift and maintain zero CLS
  5. Measure LCP and CLS before and after — image optimization is one of the highest-ROI performance improvements you can make
  6. Automate with a build pipeline — sharp or similar tools generate all formats and sizes from original source images
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX