Serving Images at Scale: CDN, Optimization, and the Formats That Actually Matter
A practical guide to image format selection, CDN caching strategies, and responsive delivery — with real file size comparisons and decision frameworks for developers.
Images Are Your Biggest Performance Problem
Open DevTools on almost any web application and look at the Network tab. Sort by size. Images dominate — typically 50-80% of total page weight, according to the HTTP Archive's annual report.
This matters because page weight directly correlates with load time, and load time directly correlates with user engagement. Google's Core Web Vitals penalize pages where the Largest Contentful Paint (LCP) takes more than 2.5 seconds, and the LCP element is an image on roughly 70% of web pages.
The three levers you have for fixing this: serve the right format, cache aggressively at the edge, and deliver the right size for each viewport. Get all three right and you can cut image payload by 60-80% without visible quality loss.
This guide covers each lever with real numbers, code snippets, and a decision framework at the end. It's useful whether you serve images from a CDN through your own infrastructure or use a managed service.
The Format Showdown: JPEG vs WebP vs AVIF
Format selection is the highest-impact optimization you can make. Let's look at a real-world comparison using a typical 1920x1080 photograph — the kind of hero image you'd find on a landing page.
These numbers come from encoding the same source image at perceptually equivalent quality levels using standard encoder settings (cjpeg, cwebp, avifenc):
| Format | High Quality | Medium Quality | Low Quality | Browser Support |
|---|---|---|---|---|
| JPEG (q85) | 420 KB | 280 KB (q70) | 150 KB (q50) | 100% |
| WebP (q80) | 280 KB (-33%) | 180 KB (-36%) | 95 KB (-37%) | ~97% |
| AVIF (q63) | 190 KB (-55%) | 120 KB (-57%) | 65 KB (-57%) | ~92% |
A few things stand out from those numbers:
WebP delivers 30-37% savings over JPEG at equivalent visual quality, and browser support is effectively universal (97%+). Unless you're targeting legacy browsers, there's no reason to serve JPEG as your primary format in 2026.
AVIF pushes savings to 50-57%, but with caveats. Encoding is significantly slower — 10-50x slower than JPEG depending on the encoder and settings. Browser support is at ~92%, which means you still need a fallback. And AVIF's progressive decoding is weaker than JPEG's, so perceived load time on slow connections can feel worse even though the file is smaller.
PNG isn't in the table because it's a different use case. PNG is lossless and designed for graphics with sharp edges, text, and transparency — screenshots, icons, diagrams. For photographic content, PNG files are 5-10x larger than JPEG and should never be used.
The practical takeaway: Serve AVIF to browsers that support it, WebP as the primary fallback, and JPEG as the universal fallback. This layered approach covers every user while maximizing savings for the majority.
For images that live in cloud image storage, getting the format right at upload time — or having your delivery layer handle it — is the single biggest win.
When to Process Images Before Upload vs On-the-Fly
There are two fundamentally different approaches to image optimization, and the right choice depends on your workload.
Pre-processing (at upload time)
Convert and resize images before storing them. This means your storage contains optimized files ready to serve — no compute at request time.
# Generate multiple formats and sizes at upload time
convert input.jpg -resize 1920x -quality 85 output-1920.jpg
cwebp -q 80 output-1920.jpg -o output-1920.webp
avifenc --min 20 --max 63 output-1920.jpg output-1920.avif
# Repeat for 1280, 640, 320 widthsPros: No processing delay on requests. CDN caches static files efficiently. Simpler infrastructure — just a static asset hosting layer.
Cons: Storage multiplied by the number of variants (3 formats x 4 sizes = 12 files per image). Re-processing required if you change quality settings later.
On-the-fly processing (at request time)
Store the original image and transform it when requested, usually with a CDN or image proxy in front.
https://cdn.example.com/images/hero.jpg?w=800&fmt=webp&q=80Pros: Store one original. Generate any variant on demand. Change optimization settings without re-uploading.
Cons: First request is slow (processing + storage + delivery). Requires compute at the edge or origin. Cache misses are expensive under high traffic.
The hybrid approach works well in practice: pre-generate the most common variants (3-4 sizes in WebP and AVIF), and use on-the-fly processing only for edge cases. This gives you fast delivery for 95% of requests and flexibility for the rest.
CDN Caching Strategies for Images
Images are ideal CDN candidates — they're large, static, and requested frequently. But getting cache headers right is the difference between a CDN that helps and one that just adds a hop.
Cache-Control headers
Set long TTLs for immutable images. If your filenames include a hash or version, you can cache forever:
Cache-Control: public, max-age=31536000, immutableFor images that might change (like user avatars), use a shorter TTL with revalidation:
Cache-Control: public, max-age=86400, stale-while-revalidate=604800The stale-while-revalidate directive tells browsers (and CDNs that support it, like Cloudflare and Fastly) to serve the cached version while revalidating in the background. Note that CloudFront does not honor this directive from Cache-Control headers — you configure its equivalent behavior separately in CloudFront cache policies.
Cache key strategy
If you serve different formats based on the Accept header, make sure your CDN includes it in the cache key. Otherwise all users get whatever format was cached first.
With CloudFront, this means adding Accept to the cache policy's header whitelist. With Nginx or Varnish, add it to the Vary header:
Vary: AcceptBe aware that adding headers to the cache key reduces your hit rate. For images, the Accept header is worth the tradeoff. Avoid adding User-Agent — it fragments the cache into thousands of variants.
Cache invalidation
The two reliable approaches:
1. URL versioning (preferred): Include a content hash in the filename (hero-a3f9b2.webp). When the image changes, the URL changes, and the old cached version expires naturally. No invalidation needed.
2. API-based invalidation: CloudFront allows 1,000 free invalidation paths per month. Beyond that, it's $0.005 per path. If you're invalidating more than a few hundred images per month, switch to URL versioning.
Using a CDN file hosting service that handles cache headers automatically eliminates most of these decisions — but understanding the mechanics helps you debug when things go wrong.
Responsive Images: srcset, sizes, and the picture Element
Serving a 1920px image to a 375px mobile screen wastes 80% of the bytes. Responsive image markup solves this by letting the browser choose the right size.
The `srcset` + `sizes` approach
Best for photographic content where you want the browser to choose the optimal size:
<img
src="hero-800.webp"
srcset="
hero-400.webp 400w,
hero-800.webp 800w,
hero-1200.webp 1200w,
hero-1920.webp 1920w
"
sizes="(max-width: 640px) 100vw,
(max-width: 1024px) 75vw,
1200px"
alt="Product screenshot"
loading="lazy"
decoding="async"
/>The sizes attribute tells the browser how wide the image will be at each breakpoint, so it can pick the right file from srcset before it downloads the CSS.
The `<picture>` element for format negotiation
Use <picture> when you want to serve different formats with explicit fallbacks:
<picture>
<source
type="image/avif"
srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1200.avif 1200w"
sizes="(max-width: 640px) 100vw, 1200px"
/>
<source
type="image/webp"
srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w"
sizes="(max-width: 640px) 100vw, 1200px"
/>
<img
src="hero-800.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
sizes="(max-width: 640px) 100vw, 1200px"
alt="Product screenshot"
loading="lazy"
decoding="async"
/>
</picture>The browser picks the first <source> it supports, then chooses the right size from that source's srcset. The <img> tag is the final fallback.
Practical tips:
- Use
loading="lazy"on all images below the fold. Never lazy-load the LCP image. - Use
decoding="async"to avoid blocking the main thread during decode. - Use
fetchpriority="high"on the LCP image to tell the browser to prioritize it. - Generate 4-5 sizes: 400, 800, 1200, 1920, and optionally 2560 for Retina displays.
- Don't over-optimize: 4 sizes x 3 formats = 12 variants per image. That's manageable. Going to 8 sizes x 3 formats = 24 variants has diminishing returns.
Choosing Your Approach: A Decision Framework
The right image delivery strategy depends on your traffic, team size, and how much infrastructure you want to manage. Here's a framework:
| Factor | Simple (Static Hosting) | Standard (CDN + Pre-processed) | Advanced (On-the-fly + CDN) |
|---|---|---|---|
| Monthly page views | < 100K | 100K - 10M | > 10M |
| Image volume | < 1,000 images | 1K - 100K images | > 100K images |
| Formats served | WebP + JPEG fallback | AVIF + WebP + JPEG | AVIF + WebP + JPEG, content-negotiated |
| Responsive sizes | 2-3 sizes | 4-5 sizes, srcset | On-demand resizing |
| Processing | Manual or build-time | Upload pipeline | Edge functions or image proxy |
| Cache strategy | Long TTL, manual deploy | Hashed filenames, immutable | Vary: Accept, stale-while-revalidate |
| Infrastructure | Static host or S3 | S3 + CloudFront or managed CDN | Image CDN (Imgix, Cloudflare) or custom pipeline |
| Engineering effort | Low (hours) | Medium (days) | High (weeks) |
| Best for | Blogs, docs, small sites | SaaS apps, e-commerce | Media-heavy platforms, marketplaces |
If you're in the "Simple" column: Pre-convert your images to WebP at 2-3 sizes during your build process. Use a <picture> element with a JPEG fallback. Host on any static file server or upload to a CDN. This handles most blogs, documentation sites, and small applications without any ongoing maintenance.
If you're in the "Standard" column: Build an upload pipeline that generates AVIF, WebP, and JPEG at 4-5 breakpoints. Store them with content-hashed filenames and serve through a CDN with immutable caching. This is the sweet spot for most SaaS applications. A managed service like files.link or a Cloudinary alternative can handle the format generation and CDN delivery so you focus on your application.
If you're in the "Advanced" column: You likely need on-the-fly image processing with aggressive edge caching. Consider a dedicated image CDN, or build a custom pipeline with Lambda@Edge or Cloudflare Workers. The engineering investment is significant, but at scale the per-image cost savings and flexibility justify it.
Next Steps
Image optimization isn't something you do once — it's a set of defaults you establish and then mostly forget about. Here's the order of operations:
- Switch your default format to WebP. This is the single highest-impact change. If your toolchain supports it, add AVIF as the preferred format with WebP as the fallback.
- Set up proper cache headers. Use content-hashed filenames and
Cache-Control: public, max-age=31536000, immutable. This alone can eliminate 90%+ of origin requests. - Add responsive markup. Use
srcsetandsizeson your most-trafficked images first. You don't need to retrofit every image on day one — start with the LCP image on your homepage and work outward. - Automate your pipeline. Whether it's a build script, an upload hook, or a managed service, make sure new images are automatically optimized. Manual optimization doesn't survive team turnover.
- Measure the results. Run Lighthouse or WebPageTest before and after. Check your Core Web Vitals in Search Console. The numbers should speak for themselves.
If you'd rather skip the infrastructure setup and go straight to serving optimized images from a global CDN, files.link handles storage and delivery so you can focus on your application. But the fundamentals in this guide apply regardless of where your images live.