Performanceintermediate

Image Optimization for Web — The Complete Guide

Master image optimization: formats, responsive images, lazy loading, CDN delivery, and next/image. Cut page weight and improve Core Web Vitals.

14 min read·Published Apr 9, 2026
performanceimagesoptimizationresponsive

Why Image Optimization Matters

Images account for roughly 50% of total page weight on the average website. Unoptimized images are the single biggest contributor to slow LCP scores and wasted bandwidth.

Average page weight breakdown:
+---------------------------------------------------+
| Images         |###################### | 50%       |
| JavaScript     |###########            | 22%       |
| CSS            |####                   | 8%        |
| Fonts          |###                    | 6%        |
| HTML           |##                     | 4%        |
| Other          |#####                  | 10%       |
+---------------------------------------------------+

A single unoptimized hero image can be 2-5MB.
The same image optimized: 50-200KB.
That is a 10-40x reduction.

Image Format Comparison

Choosing the right format is the highest-impact optimization you can make.

Format Overview

+--------+----------+--------+----------+---------+------------+
| Format | Type     | Alpha  | Animate  | Quality | Browser    |
+--------+----------+--------+----------+---------+------------+
| JPEG   | Lossy    | No     | No       | Good    | Universal  |
| PNG    | Lossless | Yes    | No       | Perfect | Universal  |
| WebP   | Both     | Yes    | Yes      | Better  | 97%+       |
| AVIF   | Lossy    | Yes    | Yes      | Best    | 93%+       |
| SVG    | Vector   | Yes    | Yes      | Infinite| Universal  |
| GIF    | Lossless | Binary | Yes      | Poor    | Universal  |
+--------+----------+--------+----------+---------+------------+

Size Comparison (Same Visual Quality)

Same 1200x800 photograph at equivalent visual quality:
+--------+-----------+------------------+
| Format | File Size | vs JPEG          |
+--------+-----------+------------------+
| PNG    | 2,400KB   | 4.8x larger      |
| JPEG   | 500KB     | baseline         |
| WebP   | 350KB     | 30% smaller      |
| AVIF   | 200KB     | 60% smaller      |
+--------+-----------+------------------+

Same 1200x800 illustration/graphic:
+--------+-----------+------------------+
| Format | File Size | vs PNG           |
+--------+-----------+------------------+
| PNG    | 800KB     | baseline         |
| WebP   | 250KB     | 69% smaller      |
| AVIF   | 150KB     | 81% smaller      |
| SVG    | 15KB*     | 98% smaller      |
+--------+-----------+------------------+
* SVG only for vector graphics, not photographs

When to Use Each Format

// Decision tree for image format selection
function chooseFormat(image) {
  if (image.type === 'icon' || image.type === 'logo' || image.type === 'illustration') {
    return 'SVG'; // Scalable, tiny, perfect quality
  }

  if (image.needsAnimation) {
    return 'WebP or AVIF'; // Replace GIF (much smaller)
  }

  if (image.needsTransparency) {
    return 'WebP with PNG fallback'; // Alpha channel support
  }

  // For photographs:
  return 'AVIF with WebP fallback, JPEG as last resort';
}

Serving Multiple Formats

<!-- Use <picture> to serve the best format each browser supports -->
<picture>
  <!-- AVIF: smallest, best quality — served to supporting browsers -->
  <source srcset="/hero.avif" type="image/avif" />
  <!-- WebP: good quality — 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="600" />
</picture>

Converting Images

# Convert to WebP using cwebp (Google's tool)
cwebp -q 80 input.jpg -o output.webp

# Convert to AVIF using avifenc
avifenc --min 20 --max 30 input.jpg output.avif

# Batch convert with sharp (Node.js)
npm install sharp
// scripts/convert-images.js
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

async function convertImage(inputPath) {
  const basename = path.basename(inputPath, path.extname(inputPath));
  const dir = path.dirname(inputPath);

  // Generate WebP
  await sharp(inputPath)
    .webp({ quality: 80 })
    .toFile(path.join(dir, `${basename}.webp`));

  // Generate AVIF
  await sharp(inputPath)
    .avif({ quality: 50 }) // AVIF quality scale is different
    .toFile(path.join(dir, `${basename}.avif`));

  // Generate multiple sizes for responsive images
  for (const width of [640, 768, 1024, 1280, 1920]) {
    await sharp(inputPath)
      .resize(width)
      .webp({ quality: 80 })
      .toFile(path.join(dir, `${basename}-${width}w.webp`));
  }
}

// Process all images in a directory
const imageDir = './public/images';
const files = fs.readdirSync(imageDir).filter(f => /\.(jpg|jpeg|png)$/i.test(f));

for (const file of files) {
  convertImage(path.join(imageDir, file));
  console.log(`Converted: ${file}`);
}

Responsive Images

Different screen sizes need different image sizes. Shipping a 4K image to a phone on 3G is wasteful.

The srcset Attribute

<!-- srcset tells the browser what sizes are available -->
<img
  src="/photo-800w.jpg"
  srcset="
    /photo-400w.jpg 400w,
    /photo-800w.jpg 800w,
    /photo-1200w.jpg 1200w,
    /photo-1600w.jpg 1600w
  "
  sizes="(max-width: 600px) 100vw,
         (max-width: 1024px) 50vw,
         800px"
  alt="Responsive photo"
  width="1600"
  height="900"
/>

How the Browser Chooses

Screen: 375px wide, 2x DPR (iPhone)
sizes says: 100vw at this width = 375px
Effective pixels needed: 375 * 2 = 750px
Browser picks: /photo-800w.jpg (closest >= 750)

Screen: 1440px wide, 1x DPR (desktop)
sizes says: 800px at this width
Effective pixels needed: 800 * 1 = 800px
Browser picks: /photo-800w.jpg (exact match)

Screen: 1440px wide, 2x DPR (Retina desktop)
sizes says: 800px at this width
Effective pixels needed: 800 * 2 = 1600px
Browser picks: /photo-1600w.jpg

The sizes Attribute

The sizes attribute tells the browser how wide the image will be at each viewport size, before the CSS is parsed.

<!--
  sizes syntax: (media-condition) size, ... , default-size

  Reads as:
  - If viewport <= 600px, image is 100% of viewport width
  - If viewport <= 1024px, image is 50% of viewport width
  - Otherwise, image is 800px wide
-->
<img
  srcset="/img-400.jpg 400w, /img-800.jpg 800w, /img-1200.jpg 1200w"
  sizes="(max-width: 600px) 100vw,
         (max-width: 1024px) 50vw,
         800px"
  alt="Example"
/>

Art Direction with <picture>

<!-- Different crops for different screen sizes -->
<picture>
  <!-- Mobile: Square crop, focus on subject -->
  <source
    media="(max-width: 600px)"
    srcset="/hero-mobile-400.webp 400w, /hero-mobile-600.webp 600w"
    sizes="100vw"
  />
  <!-- Tablet: 4:3 crop -->
  <source
    media="(max-width: 1024px)"
    srcset="/hero-tablet-768.webp 768w, /hero-tablet-1024.webp 1024w"
    sizes="100vw"
  />
  <!-- Desktop: Wide panoramic crop -->
  <source
    srcset="/hero-desktop-1200.webp 1200w, /hero-desktop-1920.webp 1920w"
    sizes="100vw"
  />
  <img src="/hero-desktop-1200.jpg" alt="Hero" width="1920" height="600" />
</picture>

Lazy Loading Images

Images below the fold should not load until the user scrolls near them.

Native Lazy Loading

<!-- Browser-native lazy loading (supported in all modern browsers) -->
<img
  src="/photo.jpg"
  alt="Photo"
  width="800"
  height="600"
  loading="lazy"        <!-- Defers loading until near viewport -->
  decoding="async"      <!-- Decodes off main thread -->
/>

<!-- NEVER lazy-load the LCP image (hero/above-the-fold) -->
<img
  src="/hero.jpg"
  alt="Hero"
  width="1200"
  height="600"
  loading="eager"       <!-- Load immediately (default) -->
  fetchpriority="high"  <!-- Prioritize this download -->
/>

loading="lazy" vs Intersection Observer

+-------------------+---------------------------+---------------------------+
| Feature           | loading="lazy"            | IntersectionObserver      |
+-------------------+---------------------------+---------------------------+
| Setup             | Zero JavaScript           | Requires JS               |
| Control           | Browser decides threshold | Custom rootMargin         |
| Placeholder       | None (blank until loaded) | Custom (blur, skeleton)   |
| Animation         | None                      | Fade-in, etc.             |
| Support           | 95%+ browsers             | 97%+ browsers             |
| Recommendation    | Default choice            | When you need custom UX   |
+-------------------+---------------------------+---------------------------+

Custom Lazy Loading with Blur Placeholder

import { useState, useRef, useEffect } from 'react';

function LazyImage({ src, alt, width, height, blurHash }) {
  const [loaded, setLoaded] = useState(false);
  const [inView, setInView] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setInView(true);
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }
    );

    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return (
    <div
      ref={ref}
      style={{
        width,
        height,
        background: loaded ? 'none' : '#e0e0e0',
        position: 'relative',
        overflow: 'hidden',
      }}
    >
      {/* Blurred placeholder */}
      {!loaded && (
        <div
          style={{
            position: 'absolute',
            inset: 0,
            backgroundImage: `url(${blurHash})`,
            backgroundSize: 'cover',
            filter: 'blur(20px)',
            transform: 'scale(1.1)',
          }}
        />
      )}

      {/* Actual image loads when in view */}
      {inView && (
        <img
          src={src}
          alt={alt}
          width={width}
          height={height}
          onLoad={() => setLoaded(true)}
          style={{
            opacity: loaded ? 1 : 0,
            transition: 'opacity 0.3s ease',
            display: 'block',
            width: '100%',
            height: '100%',
            objectFit: 'cover',
          }}
        />
      )}
    </div>
  );
}

CDN Image Delivery

A CDN (Content Delivery Network) serves images from edge servers close to the user, reducing latency and enabling on-the-fly transformations.

CDN Benefits

Without CDN:
User (Sydney) --> Origin Server (New York) = 200ms latency
User (London) --> Origin Server (New York) = 80ms latency

With CDN:
User (Sydney) --> CDN Edge (Sydney) = 10ms latency
User (London) --> CDN Edge (London) = 5ms latency

Image CDN URL Patterns

// Cloudinary
const cloudinaryUrl = (id, transforms) =>
  `https://res.cloudinary.com/demo/image/upload/${transforms}/${id}`;

// Usage
cloudinaryUrl('hero.jpg', 'w_800,h_600,c_fill,f_auto,q_auto');
// f_auto = automatic format (AVIF/WebP/JPEG based on browser)
// q_auto = automatic quality optimization

// Imgix
const imgixUrl = (path, params) => {
  const searchParams = new URLSearchParams(params);
  return `https://your-domain.imgix.net${path}?${searchParams}`;
};

imgixUrl('/hero.jpg', { w: 800, h: 600, fit: 'crop', auto: 'format,compress' });

// Vercel Image Optimization (built into Next.js)
// Automatically handled by next/image component

Implementing an Image Component with CDN

function OptimizedImage({ src, alt, width, height, sizes }) {
  // Generate srcset for multiple widths
  const widths = [640, 750, 828, 1080, 1200, 1920];
  const srcset = widths
    .filter((w) => w <= width * 2) // Don't generate sizes larger than 2x
    .map((w) => {
      const url = `https://cdn.example.com/image/${src}?w=${w}&q=80&f=auto`;
      return `${url} ${w}w`;
    })
    .join(', ');

  return (
    <img
      src={`https://cdn.example.com/image/${src}?w=${width}&q=80&f=auto`}
      srcSet={srcset}
      sizes={sizes || '100vw'}
      alt={alt}
      width={width}
      height={height}
      loading="lazy"
      decoding="async"
    />
  );
}

next/image Optimization

Next.js provides a built-in Image component that handles format conversion, resizing, lazy loading, and blur placeholders automatically.

Basic Usage

import Image from 'next/image';

// Static import (build-time optimization)
import heroImage from '../public/hero.jpg';

function Hero() {
  return (
    <Image
      src={heroImage}
      alt="Hero"
      placeholder="blur"    // Auto-generated blur placeholder
      priority               // Preloads this image (for LCP elements)
      sizes="100vw"
    />
  );
}

Remote Images

// next.config.js — whitelist remote image domains
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
      },
    ],
    // Customize generated sizes
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    // Prefer modern formats
    formats: ['image/avif', 'image/webp'],
  },
};
// Remote image with explicit dimensions
<Image
  src="https://images.unsplash.com/photo-12345"
  alt="Remote photo"
  width={1200}
  height={800}
  sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>

Fill Mode for Responsive Containers

// When you don't know the exact dimensions
function CardImage({ src, alt }) {
  return (
    <div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
      <Image
        src={src}
        alt={alt}
        fill
        sizes="(max-width: 768px) 100vw, 33vw"
        style={{ objectFit: 'cover' }}
      />
    </div>
  );
}

Blur Placeholder for Remote Images

// Generate blur data URL at build time
import { getPlaiceholder } from 'plaiceholder';

export async function getStaticProps() {
  const src = 'https://images.unsplash.com/photo-12345';
  const buffer = await fetch(src).then(r => r.arrayBuffer());
  const { base64 } = await getPlaiceholder(Buffer.from(buffer));

  return {
    props: {
      imageProps: {
        src,
        blurDataURL: base64,
      },
    },
  };
}

function Page({ imageProps }) {
  return (
    <Image
      src={imageProps.src}
      alt="Photo"
      width={1200}
      height={800}
      placeholder="blur"
      blurDataURL={imageProps.blurDataURL}
    />
  );
}

next/image Generated Output

What next/image does under the hood:

Original: /hero.jpg (2MB, 4000x2000, JPEG)

Generated at request time:
/_next/image?url=/hero.jpg&w=640&q=75   -> 25KB  (WebP/AVIF)
/_next/image?url=/hero.jpg&w=750&q=75   -> 32KB  (WebP/AVIF)
/_next/image?url=/hero.jpg&w=828&q=75   -> 38KB  (WebP/AVIF)
/_next/image?url=/hero.jpg&w=1080&q=75  -> 55KB  (WebP/AVIF)
/_next/image?url=/hero.jpg&w=1200&q=75  -> 65KB  (WebP/AVIF)
/_next/image?url=/hero.jpg&w=1920&q=75  -> 110KB (WebP/AVIF)

The browser gets the right size in the right format automatically.
Cached after first generation.

Preventing Layout Shift from Images

Images without explicit dimensions cause CLS (Cumulative Layout Shift) when they load and push surrounding content around.

Always Set Dimensions

<!-- Bad: No dimensions, causes layout shift -->
<img src="/photo.jpg" alt="Photo" />

<!-- Good: Explicit width and height -->
<img src="/photo.jpg" alt="Photo" width="800" height="600" />

<!-- Good: CSS aspect-ratio -->
<img
  src="/photo.jpg"
  alt="Photo"
  style="width: 100%; height: auto; aspect-ratio: 4/3;"
/>

CSS Aspect Ratio Containers

/* Reserve space for images before they load */
.image-wrapper {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  background: #f0f0f0; /* Placeholder color */
  overflow: hidden;
}

.image-wrapper img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* For older browsers without aspect-ratio support */
.image-wrapper-legacy {
  position: relative;
  width: 100%;
  padding-bottom: 56.25%; /* 16:9 = 9/16 = 0.5625 */
}

Advanced Techniques

Responsive Background Images

/* Serve different background images based on viewport */
.hero {
  background-image: url('/hero-mobile.webp');
  background-size: cover;
  min-height: 300px;
}

@media (min-width: 768px) {
  .hero {
    background-image: url('/hero-tablet.webp');
    min-height: 400px;
  }
}

@media (min-width: 1200px) {
  .hero {
    background-image: url('/hero-desktop.webp');
    min-height: 600px;
  }
}

/* Serve based on pixel density */
.logo {
  background-image: url('/logo-1x.png');
}

@media (min-resolution: 2dppx) {
  .logo {
    background-image: url('/logo-2x.png');
  }
}

Image Sprites for Icons

/*
  Combine multiple small icons into one image request.
  Reduces HTTP requests from N to 1.
*/
.icon {
  background-image: url('/sprites.png');
  background-repeat: no-repeat;
  width: 24px;
  height: 24px;
  display: inline-block;
}

.icon-search { background-position: 0 0; }
.icon-home   { background-position: -24px 0; }
.icon-user   { background-position: -48px 0; }
.icon-mail   { background-position: -72px 0; }

/*
  Modern alternative: Inline SVGs or SVG sprite sheet
  No HTTP requests, scalable, themeable
*/

Preloading Critical Images

<head>
  <!-- Preload the LCP image so it starts loading immediately -->
  <link
    rel="preload"
    as="image"
    href="/hero.webp"
    type="image/webp"
    fetchpriority="high"
  />

  <!-- Preload responsive image with media query -->
  <link
    rel="preload"
    as="image"
    href="/hero-mobile.webp"
    type="image/webp"
    media="(max-width: 768px)"
  />
  <link
    rel="preload"
    as="image"
    href="/hero-desktop.webp"
    type="image/webp"
    media="(min-width: 769px)"
  />
</head>

fetchpriority Attribute

<!-- High priority: LCP image, above-the-fold hero -->
<img src="/hero.jpg" alt="Hero" fetchpriority="high" />

<!-- Low priority: Below-the-fold images, thumbnails -->
<img src="/thumbnail.jpg" alt="Thumb" fetchpriority="low" loading="lazy" />

<!-- Auto (default): Browser decides -->
<img src="/content.jpg" alt="Content" />

Build Pipeline Integration

Automated Image Optimization Script

// scripts/optimize-images.js
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
const glob = require('glob');

const INPUT_DIR = './public/images/raw';
const OUTPUT_DIR = './public/images/optimized';

const SIZES = [400, 640, 768, 1024, 1280, 1920];
const FORMATS = ['webp', 'avif'];

async function optimizeImage(inputPath) {
  const basename = path.basename(inputPath, path.extname(inputPath));
  const metadata = await sharp(inputPath).metadata();

  for (const format of FORMATS) {
    for (const width of SIZES) {
      if (width > metadata.width) continue; // Skip upscaling

      const outputPath = path.join(
        OUTPUT_DIR,
        format,
        `${basename}-${width}w.${format}`
      );

      await sharp(inputPath)
        .resize(width)
        [format]({ quality: format === 'avif' ? 50 : 80 })
        .toFile(outputPath);
    }
  }

  // Also generate a JPEG fallback at original size
  await sharp(inputPath)
    .jpeg({ quality: 85, progressive: true })
    .toFile(path.join(OUTPUT_DIR, 'jpeg', `${basename}.jpg`));
}

async function main() {
  // Create output directories
  for (const format of [...FORMATS, 'jpeg']) {
    fs.mkdirSync(path.join(OUTPUT_DIR, format), { recursive: true });
  }

  const files = glob.sync(`${INPUT_DIR}/**/*.{jpg,jpeg,png}`);
  console.log(`Optimizing ${files.length} images...`);

  for (const file of files) {
    await optimizeImage(file);
    console.log(`Done: ${path.basename(file)}`);
  }

  console.log('All images optimized.');
}

main();
// package.json
{
  "scripts": {
    "images:optimize": "node scripts/optimize-images.js",
    "prebuild": "npm run images:optimize"
  }
}

Image Optimization Checklist

+---+-------------------------------------------+-------------+
| # | Optimization                              | Impact      |
+---+-------------------------------------------+-------------+
| 1 | Use WebP/AVIF formats                     | High        |
| 2 | Serve responsive sizes (srcset + sizes)   | High        |
| 3 | Lazy load below-the-fold images           | High        |
| 4 | Set width/height to prevent CLS           | High        |
| 5 | Preload LCP image with fetchpriority      | High        |
| 6 | Use a CDN for delivery                    | Medium-High |
| 7 | Compress with appropriate quality (75-85)  | Medium      |
| 8 | Use blur placeholders for perceived speed  | Medium      |
| 9 | Use SVG for icons, logos, illustrations    | Medium      |
| 10| Remove EXIF metadata                       | Low         |
| 11| Use progressive JPEG for fallbacks         | Low         |
| 12| Use CSS for simple shapes (no image needed)| Low         |
+---+-------------------------------------------+-------------+

Key Takeaways

  • Images are 50% of average page weight — optimizing them has the highest ROI
  • AVIF offers the best compression; WebP is the safe modern choice; JPEG is the universal fallback
  • Use <picture> with multiple <source> elements for format and art direction
  • Always set width and height on images to prevent CLS
  • Use loading="lazy" for below-the-fold images, fetchpriority="high" for LCP images
  • The srcset and sizes attributes let the browser pick the right image size
  • next/image handles format conversion, resizing, lazy loading, and caching automatically
  • CDNs reduce latency and can transform images on the fly
  • Automate image optimization in your build pipeline — do not rely on manual processes
  • A 10x image size reduction is common when switching from unoptimized PNG/JPEG to properly sized WebP/AVIF

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles