Shipping unoptimized images is the single fastest way to tank your Core Web Vitals. A 4000x3000 JPEG straight from a camera is 8-12MB. Processed correctly, the same image serves at 50-150KB without visible quality loss. That's a 98% reduction — and it's entirely automatable.
This guide walks through building a complete image pipeline: from source file to browser pixel. Every stage reduces waste — wrong resolution, wrong format, wrong quality, wrong loading strategy. Skip any stage and you leave performance on the table.
The pipeline has five stages: source management, build-time processing, responsive image markup, modern format generation, and CDN delivery. Each stage is independent — you can implement them incrementally.
Stage 1: Source Format Strategy
Your pipeline is only as good as its inputs. Store source images at the highest quality available — you can always compress down, never up.
For photography: Store originals as full-resolution JPG (quality 95-100) or PNG. If your photographers shoot RAW, convert to lossless TIFF or high-quality PNG for the archive. Never use a lossy format as your archive source — re-encoding lossy files compounds quality loss (generation loss).
For graphics and illustrations: Store as SVG when possible. For raster graphics (screenshots, diagrams with raster elements), store as PNG. Never store graphics as JPG — the DCT compression creates artifacts around sharp edges and text.
For user uploads: Accept whatever format users provide but normalize immediately. Convert HEIC to JPG (iPhones), convert BMP to PNG (Windows screenshots), convert TIFF to JPG (scanners). Store the normalized version as your source.
Directory Structure for Source Images
content/
images/
originals/ # Full-resolution source files (never served directly)
hero-shot.png
team-photo.jpg
processed/ # Build output (gitignored)
hero-shot/
hero-shot-400.webp
hero-shot-800.webp
hero-shot-1200.webp
hero-shot-400.avif
hero-shot-800.avif
hero-shot-1200.avif
hero-shot-800.jpg # Fallback
Keep originals in version control (or a separate asset store for large files). Keep processed output in .gitignore — it's generated from source, same as compiled code.
Stage 2: Build-Time Processing with sharp
sharp is the gold standard for Node.js image processing. It wraps libvips, which is faster than ImageMagick/GraphicsMagick for batch operations. A 12MP resize takes ~50ms with sharp versus ~300ms with ImageMagick.
import sharp from 'sharp';
const widths = [400, 800, 1200, 1600];
const formats = ['webp', 'avif', 'jpg'];
async function processImage(inputPath, outputDir, name) {
const pipeline = sharp(inputPath);
const metadata = await pipeline.metadata();
for (const width of widths) {
// Skip sizes larger than the original
if (width > metadata.width) continue;
for (const format of formats) {
const options = getFormatOptions(format);
await sharp(inputPath)
.resize(width, null, { withoutEnlargement: true })
.toFormat(format, options)
.toFile(`${outputDir}/${name}-${width}.${format}`);
}
}
}
function getFormatOptions(format) {
switch (format) {
case 'webp': return { quality: 80, effort: 4 };
case 'avif': return { quality: 65, effort: 4 }; // AVIF needs lower quality number for same visual result
case 'jpg': return { quality: 80, progressive: true, mozjpeg: true };
}
}Quality settings: WebP 80 and AVIF 65 produce roughly equivalent visual quality to JPG 85. AVIF's quality scale is not linear with JPG's — always compare visually, never match numbers. MozJPEG encoding (via sharp's mozjpeg: true) produces 5-10% smaller JPGs than libjpeg at the same quality.
Alternative: squoosh-cli
Google's squoosh-cli is a good alternative if you don't want Node.js dependencies. It wraps the same codecs used in squoosh.app:
# Resize and convert to WebP
npx @squoosh/cli --resize '{width: 800}' --webp '{quality: 80}' -d output/ input.jpg
# Generate AVIF
npx @squoosh/cli --avif '{quality: 50, speed: 4}' -d output/ input.jpgsquoosh-cli is good for one-off conversions and CI pipelines. For production build systems with hundreds of images, sharp's Node.js API is more flexible and faster due to libvips' streaming architecture.
Stage 3: Responsive Image Markup
Generating multiple sizes is pointless without telling the browser which one to load. This is where srcset and sizes come in.
<picture>
<source
type="image/avif"
srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1200.avif 1200w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 600px"
>
<source
type="image/webp"
srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 600px"
>
<img
src="hero-800.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 600px"
alt="Hero image description"
width="1200"
height="800"
loading="lazy"
decoding="async"
>
</picture>The sizes attribute is critical. Without it, the browser assumes the image is 100vw (full viewport width) and downloads the largest variant every time. Measure your actual layout: if an image displays at 50% viewport width on desktop, say so in sizes.
Choosing Width Breakpoints
Don't match your CSS breakpoints. Instead, choose image widths based on where the file size jumps significantly. A good heuristic: generate images at 400w, 800w, 1200w, and 1600w. That covers mobile (1x and 2x) through desktop (1x and 2x) with at most 2x oversizing.
For hero images that span the full viewport, add 2000w and 2400w. For thumbnails that never exceed 200px display width, 200w and 400w (for 2x) is sufficient.
Each additional variant is a build-time cost (storage, CDN cache) but a runtime benefit (precise sizing). 4-5 variants per image is the sweet spot for most sites.
Stage 4: Modern Format Generation
The format cascade in the <picture> element lets you serve AVIF to browsers that support it, WebP to the rest, and JPG as a universal fallback. The browser picks the first <source> it can decode.
AVIF encoding time is the bottleneck. At effort level 4, encoding a 1200px-wide AVIF takes 1-3 seconds. A site with 500 images generates 2000+ AVIF variants (500 images x 4 sizes). At 2 seconds each, that's over an hour of encoding. Solutions:
- Parallel encoding: sharp supports concurrent operations. Run 4-8 encoders in parallel to saturate CPU cores.
- Incremental builds: Track source file hashes. Only re-encode when the source changes.
- CDN generation: Skip build-time AVIF entirely and let the CDN generate it on-the-fly (see Stage 5).
Content negotiation via Accept header: Some setups skip the <picture> element entirely. Instead, the CDN reads the browser's Accept: image/avif, image/webp header and returns the best format from a single URL. Cloudflare, imgix, and Cloudinary all support this. The downside: you lose art direction capabilities.
Stage 5: CDN Delivery and Transformation
A CDN with image transformation capabilities can replace Stages 2-4 entirely. Instead of generating variants at build time, you upload one high-quality source and let the CDN handle resizing, format conversion, and quality optimization at the edge.
Cloudflare Image Resizing (with Workers):
// Cloudflare Worker — transform images on the fly
export default {
async fetch(request) {
const url = new URL(request.url);
// Only transform image paths
if (!url.pathname.startsWith('/images/')) return fetch(request);
return fetch(request, {
cf: {
image: {
width: parseInt(url.searchParams.get('w')) || 800,
quality: 80,
format: 'auto', // Reads Accept header, returns AVIF/WebP/JPG
fit: 'cover',
}
}
});
}
};imgix uses URL parameters: https://example.imgix.net/photo.jpg?w=800&auto=format,compress
Cloudinary uses path-based transforms: https://res.cloudinary.com/demo/image/upload/w_800,f_auto,q_auto/photo.jpg
Tradeoff: CDN transformation adds a few milliseconds of latency on the first request (cache miss). Subsequent requests are served from cache at full CDN speed. For high-traffic sites, the cache hit rate is 95%+ and the latency is negligible. For low-traffic sites with many unique images, the cold-cache penalty is more noticeable.
Loading Strategy: Lazy, Eager, and Preload
Format and size optimization are wasted if you load every image eagerly on page load. Use a three-tier loading strategy:
Tier 1 — Preload (LCP image): Your Largest Contentful Paint image should be preloaded in the <head>:
<link rel="preload" as="image" type="image/avif"
href="hero-1200.avif"
imagesrcset="hero-400.avif 400w, hero-800.avif 800w, hero-1200.avif 1200w"
imagesizes="100vw"
fetchpriority="high">Tier 2 — Eager (above-the-fold): Images visible on initial viewport load should use default loading (no loading attribute) with decoding="async". Don't lazy-load these — the Intersection Observer overhead actually delays them.
Tier 3 — Lazy (below-the-fold): Everything below the fold gets loading="lazy". The browser handles the rest using native lazy loading, which triggers when the image enters a ~1250px threshold above the viewport.
<!-- Tier 2: above the fold -->
<img src="product.webp" alt="Product" width="400" height="300" decoding="async">
<!-- Tier 3: below the fold -->
<img src="testimonial.webp" alt="Customer" width="200" height="200" loading="lazy" decoding="async">
Measuring Pipeline Effectiveness
After implementing the pipeline, verify results with real metrics:
- Lighthouse: "Properly size images" and "Serve images in next-gen formats" audits should pass
- WebPageTest: Check the image request waterfall — are images the right size for the viewport? Are modern formats being served?
- DevTools Network tab: Filter by
img, check transferred sizes. Compare before/after. - CrUX data: Monitor LCP over time. A good pipeline should get LCP under 2.5s on mobile for image-heavy pages.
Target sizes by image type:
| Image Type | Display Width | Target File Size |
|---|---|---|
| Hero/banner | Full width | 50-150KB (WebP/AVIF) |
| Product/card | 300-500px | 15-40KB |
| Thumbnail | 100-200px | 3-10KB |
| Avatar | 48-96px | 2-5KB |
| Logo/icon | Any (SVG) | 1-5KB |
A well-built image pipeline reduces total image weight by 90%+ compared to serving unprocessed originals. The implementation effort is front-loaded — once the pipeline runs, every new image automatically gets optimal treatment.
Start with the highest-impact stage for your current situation. If you're serving unresized images, start with build-time resizing. If you're already resizing but using JPG, add WebP and AVIF generation. If you have everything but lazy loading, add the three-tier loading strategy. Each stage stacks independently.