StackPractices
beginner By Mathias Paulenko

Image Optimization

How to resize, compress, and optimize images for web performance.

Overview

Images are the single largest contributor to page weight. Unoptimized images slow down your site, hurt SEO rankings, and increase bandwidth costs. Here is how to resizing, compressing, and converting images to modern formats (WebP, AVIF) in Python, JavaScript, and Java, plus responsive image strategies for the web.

When to Use

Use this resource when:

  • Users upload photos that need resizing before storage. See File Upload Validation for secure upload handling.
  • You need to generate multiple image sizes for responsive layouts. See SPA Code Splitting for responsive asset delivery.
  • Your Core Web Vitals report flags images for optimization. See Compression Gzip for additional payload reduction.
  • You want to convert legacy formats (JPEG, PNG) to WebP/AVIF. See Stream Processing for batch format conversion.

Solution

Python (Pillow + Pillow-WebP)

from PIL import Image
import io

def optimize_image(input_path, output_path, max_width=1200, quality=85):
    with Image.open(input_path) as img:
        # Convert to RGB if necessary (strip alpha for JPEG)
        if img.mode in ("RGBA", "P"):
            img = img.convert("RGB")

        # Resize if larger than max_width
        if img.width > max_width:
            ratio = max_width / img.width
            new_size = (max_width, int(img.height * ratio))
            img = img.resize(new_size, Image.Resampling.LANCZOS)

        # Save as WebP with quality setting
        img.save(output_path, "WEBP", quality=quality, method=6)
        return output_path

# Generate responsive sizes
def generate_responsive(input_path, prefix):
    sizes = [320, 640, 1024, 1920]
    for width in sizes:
        optimize_image(input_path, f"{prefix}-{width}.webp", max_width=width)

optimize_image("photo.jpg", "photo-optimized.webp")

JavaScript (Sharp)

const sharp = require("sharp");
const fs = require("fs");

async function optimizeImage(inputPath, outputPath, options = {}) {
  const { width = 1200, quality = 85, format = "webp" } = options;

  let pipeline = sharp(inputPath)
    .resize(width, null, { withoutEnlargement: true })
    .sharpen({ sigma: 1.0 });

  if (format === "webp") {
    pipeline = pipeline.webp({ quality, effort: 6 });
  } else if (format === "avif") {
    pipeline = pipeline.avif({ quality, effort: 4 });
  } else if (format === "jpeg") {
    pipeline = pipeline.jpeg({ quality, mozjpeg: true });
  }

  await pipeline.toFile(outputPath);
  return outputPath;
}

async function generateResponsive(inputPath, prefix) {
  const sizes = [320, 640, 1024, 1920];
  for (const width of sizes) {
    await optimizeImage(inputPath, `${prefix}-${width}.webp`, { width });
  }
}

optimizeImage("photo.jpg", "photo-optimized.webp");

Java (Thumbnailator)

import net.coobird.thumbnailator.Thumbnails;
import net.coobird.thumbnailator.geometry.Positions;
import javax.imageio.ImageIO;
import java.io.File;
import java.io.IOException;

public class ImageOptimizer {
    public static void optimize(String inputPath, String outputPath, int maxWidth, float quality) throws IOException {
        Thumbnails.of(new File(inputPath))
            .width(maxWidth)
            .outputQuality(quality)
            .outputFormat("jpg")
            .toFile(new File(outputPath));
    }

    public static void generateResponsive(String inputPath, String prefix) throws IOException {
        int[] sizes = {320, 640, 1024, 1920};
        for (int width : sizes) {
            optimize(inputPath, prefix + "-" + width + ".jpg", width, 0.85f);
        }
    }

    public static void main(String[] args) throws IOException {
        optimize("photo.jpg", "photo-optimized.jpg", 1200, 0.85f);
    }
}

Explanation

Image optimization has three dimensions:

  1. Dimensions (resize): Serving a 4000x3000 image on a 400px-wide container wastes 90% of pixels. Resize to the display size.
  2. Format (transcode): WebP is ~25-35% smaller than JPEG at the same quality. AVIF is ~50% smaller but slower to encode.
  3. Quality (compress): Lower quality settings (60-85) are often visually indistinguishable from 100% while saving major bytes.

For web delivery, combine server-side optimization with <picture> elements that serve the right format and size based on device capabilities.

Variants

ApproachToolProsCons
Server-side (batch)Sharp, PillowFull control, cachingRequires compute
On-uploadLambda / Cloud FunctionAutomatic, scalableCold start latency
CDN (on-the-fly)Cloudflare Images, ImgixZero code, edge cachedVendor lock-in, cost
Client-sideCanvas APIInstant previewPoor quality, JS overhead
Command lineImageMagick, cwebpScriptable, CI-friendlyManual, no runtime

What Works

  • Resize before compressing: Compressing a 4K image to 10KB produces artifacts; resize first.
  • Use WebP as default: Fallback to JPEG for older browsers via <picture>.
  • Implement lazy loading: loading="lazy" prevents offscreen images from blocking LCP.
  • Set width and height attributes: Prevents layout shift (CLS) while images load.
  • Use responsive srcset: Serve different sizes for mobile vs desktop without manual logic.

Common Mistakes

  • Serving original uploads: Users upload 10MB iPhone photos; always process before storage.
  • Ignoring EXIF orientation: Photos appear sideways if Orientation metadata isn’t handled.
  • Lossy compression on PNGs: PNG is lossless; use WebP or JPEG for photos instead.
  • No fallback for WebP: Safari <14 doesn’t support WebP. Provide JPEG fallback.
  • Forgetting AVIF: For critical above-the-fold images, AVIF’s smaller size justifies the encoding time.

Advanced Solutions

Python: Batch optimization with EXIF stripping and perceptual quality

from PIL import Image, ImageOps, ExifTags
import os
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor, as_completed

def strip_exif(img: Image.Image) -> Image.Image:
    """Remove EXIF metadata for privacy and file size reduction."""
    data = list(img.getdata())
    img_without_exif = Image.new(img.mode, img.size)
    img_without_exif.putdata(data)
    return img_without_exif

def auto_orient(img: Image.Image) -> Image.Image:
    """Apply EXIF orientation tag to the image pixels."""
    try:
        exif = img._getexif()
        if exif:
            orientation = exif.get(0x0112, 1)
            transforms = {
                2: Image.FLIP_LEFT_RIGHT,
                3: Image.ROTATE_180,
                4: Image.FLIP_TOP_BOTTOM,
                5: Image.TRANSPOSE,
                6: Image.ROTATE_270,
                7: Image.TRANSVERSE,
                8: Image.ROTATE_90,
            }
            if orientation in transforms:
                img = img.transpose(transforms[orientation])
    except (AttributeError, KeyError, TypeError):
        pass
    return img

def optimize_for_web(
    input_path: str,
    output_dir: str,
    sizes: list[int] = None,
    quality: int = 82,
    formats: list[str] = None,
) -> dict:
    """Generate optimized responsive images in multiple formats."""
    if sizes is None:
        sizes = [320, 640, 1024, 1920]
    if formats is None:
        formats = ["webp", "jpeg"]

    stem = Path(input_path).stem
    results = {}

    with Image.open(input_path) as img:
        img = auto_orient(img)
        if img.mode in ("RGBA", "P", "LA"):
            img = img.convert("RGB")

        original_width = img.width
        metadata = {
            "original_size": os.path.getsize(input_path),
            "original_dimensions": f"{img.width}x{img.height}",
            "outputs": [],
        }

        for width in sizes:
            if width > original_width:
                continue

            ratio = width / original_width
            new_height = int(img.height * ratio)
            resized = img.resize((width, new_height), Image.Resampling.LANCZOS)

            for fmt in formats:
                output_path = os.path.join(output_dir, f"{stem}-{width}.{fmt}")
                if fmt == "webp":
                    resized.save(output_path, "WEBP", quality=quality, method=6)
                elif fmt == "avif":
                    resized.save(output_path, "AVIF", quality=quality)
                elif fmt == "jpeg":
                    resized.save(output_path, "JPEG", quality=quality, optimize=True)

                file_size = os.path.getsize(output_path)
                metadata["outputs"].append({
                    "path": output_path,
                    "width": width,
                    "format": fmt,
                    "size_bytes": file_size,
                    "size_kb": round(file_size / 1024, 1),
                })
                results[f"{width}.{fmt}"] = output_path

    metadata["total_outputs"] = len(metadata["outputs"])
    return results

def batch_optimize(
    input_dir: str,
    output_dir: str,
    extensions: tuple = (".jpg", ".jpeg", ".png", ".webp"),
    max_workers: int = 4,
) -> list[dict]:
    """Batch optimize all images in a directory using multiprocessing."""
    os.makedirs(output_dir, exist_ok=True)
    files = [
        os.path.join(input_dir, f)
        for f in os.listdir(input_dir)
        if f.lower().endswith(extensions)
    ]

    results = []
    with ProcessPoolExecutor(max_workers=max_workers) as executor:
        futures = {
            executor.submit(optimize_for_web, f, output_dir): f
            for f in files
        }
        for future in as_completed(futures):
            file_path = futures[future]
            try:
                result = future.result()
                results.append({"file": file_path, "status": "ok", "result": result})
            except Exception as e:
                results.append({"file": file_path, "status": "error", "error": str(e)})

    return results

# Usage
# results = batch_optimize("./uploads", "./optimized")
# for r in results:
#     if r["status"] == "ok":
#         print(f"OK: {r['file']}")
#     else:
#         print(f"FAIL: {r['file']} - {r['error']}")

Node.js: Sharp pipeline with AVIF and streaming

const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

async function optimizeImageAdvanced(inputPath, outputDir, options = {}) {
    const {
        sizes = [320, 640, 1024, 1920],
        quality = 82,
        formats = ['webp', 'avif'],
        stripExif = true,
    } = options;

    const stem = path.basename(inputPath, path.extname(inputPath));
    const metadata = await sharp(inputPath).metadata();
    const results = [];

    for (const width of sizes) {
        if (width > metadata.width) continue;

        for (const fmt of formats) {
            const outputPath = path.join(outputDir, `${stem}-${width}.${fmt}`);
            let pipeline = sharp(inputPath)
                .rotate() // Auto-orient based on EXIF
                .resize(width, null, { withoutEnlargement: true })
                .sharpen({ sigma: 1.0 });

            if (stripExif) {
                pipeline = pipeline.withMetadata({ exif: {} });
            }

            if (fmt === 'webp') {
                pipeline = pipeline.webp({ quality, effort: 6 });
            } else if (fmt === 'avif') {
                pipeline = pipeline.avif({ quality, effort: 4 });
            } else if (fmt === 'jpeg') {
                pipeline = pipeline.jpeg({ quality, mozjpeg: true });
            }

            const info = await pipeline.toFile(outputPath);
            results.push({
                path: outputPath,
                width: info.width,
                format: info.format,
                size: info.size,
            });
        }
    }

    return results;
}

// Worker-based batch processing
function batchOptimizeWorker(inputDir, outputDir, maxWorkers = 4) {
    return new Promise((resolve, reject) => {
        const files = fs.readdirSync(inputDir)
            .filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
            .map(f => path.join(inputDir, f));

        const results = [];
        let completed = 0;
        let activeWorkers = 0;
        let fileIndex = 0;

        function startNext() {
            while (activeWorkers < maxWorkers && fileIndex < files.length) {
                const file = files[fileIndex++];
                activeWorkers++;

                const worker = new Worker(__filename, {
                    workerData: { file, outputDir },
                });

                worker.on('message', (msg) => {
                    results.push(msg);
                });
                worker.on('error', (err) => {
                    results.push({ file, status: 'error', error: err.message });
                });
                worker.on('exit', () => {
                    activeWorkers--;
                    completed++;
                    if (completed === files.length) {
                        resolve(results);
                    } else {
                        startNext();
                    }
                });
            }
        }

        if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
        startNext();
    });
}

if (!isMainThread) {
    // Worker thread code
    optimizeImageAdvanced(workerData.file, workerData.outputDir)
        .then(result => parentPort.postMessage({ file: workerData.file, status: 'ok', result }))
        .catch(err => parentPort.postMessage({ file: workerData.file, status: 'error', error: err.message }));
}

// Usage (main thread)
// batchOptimizeWorker('./uploads', './optimized', 4).then(results => {
//     results.forEach(r => console.log(`${r.status}: ${r.file}`));
// });

HTML: Responsive image delivery with <picture> and srcset

<!-- Responsive picture with format negotiation -->
<picture>
  <source
    type="image/avif"
    srcset="photo-320.avif 320w, photo-640.avif 640w, photo-1024.avif 1024w, photo-1920.avif 1920w"
    sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw"
  >
  <source
    type="image/webp"
    srcset="photo-320.webp 320w, photo-640.webp 640w, photo-1024.webp 1024w, photo-1920.webp 1920w"
    sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw"
  >
  <img
    src="photo-1024.jpg"
    srcset="photo-320.jpg 320w, photo-640.jpg 640w, photo-1024.jpg 1024w, photo-1920.jpg 1920w"
    sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw"
    width="1024"
    height="768"
    alt="Product photo"
    loading="lazy"
    decoding="async"
  >
</picture>

<!-- LCP image: eager load, high priority -->
<picture>
  <source type="image/avif" srcset="hero-1920.avif" fetchpriority="high">
  <source type="image/webp" srcset="hero-1920.webp" fetchpriority="high">
  <img
    src="hero-1920.jpg"
    width="1920"
    height="1080"
    alt="Hero banner"
    fetchpriority="high"
    decoding="async"
  >
</picture>

Bash: Batch optimization with cwebp and ImageMagick

#!/usr/bin/env bash
set -euo pipefail

# Requires: webp (cwebp), imagemagick (convert, identify)
# Install: apt install webp imagemagick

INPUT_DIR="${1:?Usage: $0 <input_dir> <output_dir>}"
OUTPUT_DIR="${2:-./optimized}"
SIZES=(320 640 1024 1920)
QUALITY=82

mkdir -p "$OUTPUT_DIR"

for img in "$INPUT_DIR"/*.{jpg,jpeg,png}; do
    [ -f "$img" ] || continue
    stem=$(basename "${img%.*}")
    original_width=$(identify -format "%w" "$img" 2>/dev/null || echo 0)

    for width in "${SIZES[@]}"; do
        if [ "$width" -gt "$original_width" ]; then
            continue
        fi

        # Auto-orient and resize
        temp_png=$(mktemp --suffix=.png)
        convert "$img" -auto-orient -resize "${width}x" "$temp_png"

        # Convert to WebP
        cwebp -q "$QUALITY" -m 6 "$temp_png" -o "$OUTPUT_DIR/${stem}-${width}.webp" 2>/dev/null

        # Convert to JPEG fallback
        convert "$temp_png" -quality "$QUALITY" "$OUTPUT_DIR/${stem}-${width}.jpg" 2>/dev/null

        rm -f "$temp_png"
        echo "Generated: ${stem}-${width}.webp + ${stem}-${width}.jpg"
    done
done

echo "Batch optimization complete: $OUTPUT_DIR"

Frequently Asked Questions

Should I optimize images on upload or on request?

On upload is best for predictable workloads: process once, cache forever. On request (CDN or lambda) is better for live sizes or when you can't control the source. Many production systems do both: optimize on upload for common sizes, generate rare sizes on-the-fly.

How do I handle animated GIFs?

Convert to animated WebP or MP4 (H.264). Animated GIFs are incredibly inefficient — a 2MB GIF often becomes a 200KB WebP or 100KB MP4. Use <video autoplay loop muted playsinline> for MP4 fallback.

What quality setting should I use?
  • WebP photos: 75-85
  • JPEG photos: 80-90
  • Screenshots/illustrations: 90-95 (sharp edges show artifacts)
  • Thumbnails: 60-70 (small size hides artifacts)

Always A/B test with your actual content. Automated perceptual metrics (SSIM, Butteraugli) can optimize quality per-image.