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:
- Dimensions (resize): Serving a 4000x3000 image on a 400px-wide container wastes 90% of pixels. Resize to the display size.
- Format (transcode): WebP is ~25-35% smaller than JPEG at the same quality. AVIF is ~50% smaller but slower to encode.
- 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
| Approach | Tool | Pros | Cons |
|---|---|---|---|
| Server-side (batch) | Sharp, Pillow | Full control, caching | Requires compute |
| On-upload | Lambda / Cloud Function | Automatic, scalable | Cold start latency |
| CDN (on-the-fly) | Cloudflare Images, Imgix | Zero code, edge cached | Vendor lock-in, cost |
| Client-side | Canvas API | Instant preview | Poor quality, JS overhead |
| Command line | ImageMagick, cwebp | Scriptable, CI-friendly | Manual, 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
widthandheightattributes: 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
Orientationmetadata 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.
Related Resources
File Upload Validation
How to handle file uploads securely with size, type, and content validation.
RecipeGenerate PDFs
How to generate PDF documents programmatically from HTML, templates, or raw data.
RecipeRead and Write Files
How to read from and write to files safely across multiple programming languages.
PatternAbstract Factory Pattern
Create families of related objects without specifying concrete classes. A creational design pattern for consistent object families.
PatternAdapter Pattern
Convert the interface of a class into another interface clients expect. A structural design pattern for interface compatibility.
RecipeCompress and Decompress Files with Gzip and Brotli
How to reduce file sizes for APIs, static assets, and log files using Gzip, Brotli, and zlib with streaming compression, content negotiation, and what works.