Images still account for more than half the bytes on most content sites I audit, and the fastest way to cut that weight is not to compress harder but to stop sending the wrong format and size to the wrong device. A modern image pipeline at the edge — with on-the-fly AVIF and WebP generation, responsive markup, and smart caching — routinely shaves 40-70% off image payload without a visible quality loss. In my experience, teams that try to handle this at build time with three static variants quickly run into stale assets, exploding storage, and layout shift regressions. Moving the logic to the CDN edge, where you can negotiate format, resize by URL parameters, and cache the result close to the user, is more maintainable and consistently faster.
Why One-Size-Fits-All JPEGs Drain Bandwidth and Delay LCP
Serving a single 2400px JPEG to every client is convenient for the CMS, but it penalizes everyone. A phone on a 390px viewport with a 2x display needs at most 780px of width. Sending triple that forces the browser to download, decode, and downscale data it will never render. On a product listing page with 20 thumbnails, that overhead multiplies directly into Largest Contentful Paint and data costs.
The cost is not just bytes. JPEG was designed in 1992 for photographic content with discrete cosine transforms. It has no native support for alpha, no efficient lossless mode, and its compression artifacts become obvious below quality 75. For logos, UI elements, and high-contrast illustrations, JPEG is often twice the size of a modern alternative at the same visual quality measured by SSIM or Butteraugli.
I've found that the tipping point for teams is usually the Web Performance budget. When your Core Web Vitals Optimization: LCP, CLS and INP Improvement Strategies work stalls because the LCP element is a hero image that weighs 380 KB as a JPEG, format savings become the highest-leverage fix. Switching that hero to AVIF at quality 50 often drops it to 95 KB, and to WebP at quality 75 to about 140 KB, with no perceptible difference on a calibrated display.
Quantifying the Gap Before You Build the Pipeline
Before adding edge logic, profile your real traffic. Pull your CDN logs for a week and group image requests by User-Agent width buckets and Accept header. I typically see three clusters: 30-35% of requests support AVIF (Chrome 85+, Firefox 93+, Safari 16.4+), 75-85% support WebP, and nearly 100% fall back to JPEG. If your origin still serves only JPEG, you are missing compression gains on 70%+ of traffic.
Correlate that with your origin image dimensions. In one retail migration I worked on, the median origin JPEG was 2048px wide while the median rendered width reported by client-side analytics was 412px. That mismatch alone meant over 60% of pixels were wasted. Those numbers justify a resizing and format-negotiation layer at the edge even before you tune quality parameters.
How Content Negotiation Delivers AVIF and WebP Without Cache Poisoning
The cleanest way to serve multiple formats from a single URL is HTTP content negotiation via the Accept request header. A browser that supports AVIF sends Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 while an older Safari might send only Accept: image/webp,*/* or no image-specific preference. Your edge server reads that header, selects the best-supported format you have encoded, and returns it with the appropriate Content-Type.
This approach keeps your HTML simple — the src stays /images/hero.jpg — and lets the CDN decide the optimal bytes. According to the HTTP Working Group Specifications on content negotiation and the web.dev Articles on serving AVIF, you must signal that the response varies by that header. Failing to do so is the most common cause of cache poisoning I see in the wild.
# Example: Edge responds with AVIF when supported, WebP as fallback
# Request from modern Chrome
GET /images/hero.jpg HTTP/1.1
Accept: image/avif,image/webp,*/*
User-Agent: Mozilla/5.0 ...
# Edge response
HTTP/1.1 200 OK
Content-Type: image/avif
Content-Length: 98234
Vary: Accept
Cache-Control: public, max-age=31536000, immutable
CDN-Cache-Status: HIT
# Request from older client without AVIF/WebP
GET /images/hero.jpg HTTP/1.1
Accept: */*
# Edge response (fallback)
HTTP/1.1 200 OK
Content-Type: image/jpeg
Vary: Accept
Cache-Control: public, max-age=31536000, immutable
The Vary: Accept header tells intermediate caches to store multiple variants keyed by the normalized Accept value. Without it, a cache that stores the AVIF response first will serve AVIF bytes with an image/avif Content-Type to a client that only understands JPEG, resulting in a broken image. In my experience, normalizing the Vary key at the edge — mapping the full Accept header down to three buckets (avif, webp, jpeg) — prevents cache fragmentation where dozens of slightly different Accept strings create duplicate cache entries for the same logical variant.
Separating Format Negotiation from URL-Based Variants
Some teams prefer explicit URLs like /images/hero.avif or /cdn-cgi/image/format=auto,width=800/hero.jpg. URL-based transforms are excellent for resizing, but for format selection I still recommend Accept-based negotiation behind a canonical JPEG URL for two reasons. First, your HTML remains cacheable and format-agnostic. Second, you avoid having to rewrite srcset entries for every new format you adopt.
If you do expose transformation parameters in the URL, secure them. Sign the parameters or restrict allowed widths and qualities at the edge. I've seen open resizers exploited to generate thousands of 4000px variants that fill cache and origin storage. An allowlist like width in [320, 640, 828, 1080, 1280, 1920] and quality in [60, 75, 85] keeps the variant cardinality predictable.
Constructing Responsive Markup That Respects Viewport, DPR, and Art Direction
Even with a perfect edge pipeline, the browser still decides which file to request. That decision comes from srcset, sizes, and optionally the <picture> element. Getting these wrong negates your edge savings — the browser will happily download a 1920px WebP for a 320px slot if you tell it that slot is 100vw on all viewports.
The srcset with w descriptors lets the browser pick based on layout width, viewport size, and device pixel ratio. The sizes attribute is your contract with the browser about how wide the image will render at different breakpoints. It directly influences which candidate is fetched before CSS is even parsed, so it affects the preload scanner and LCP.
<!-- Responsive image with CDN-driven resizing and automatic format negotiation -->
<!-- The CDN reads Accept header, but srcset still controls resolution switching -->
<img
src="/images/hero.jpg"
srcset="/images/hero.jpg?width=640 640w,
/images/hero.jpg?width=828 828w,
/images/hero.jpg?width=1280 1280w,
/images/hero.jpg?width=1920 1920w"
sizes="(max-width: 640px) 100vw,
(max-width: 1024px) 50vw,
33vw"
width="1920"
height="1080"
alt="Product hero showing detail and texture"
loading="eager"
decoding="async"
fetchpriority="high">
<!-- Use <picture> only when you need art direction, not just format switching -->
<picture>
<source media="(max-width: 640px)" srcset="/images/hero-crop-square.jpg?width=640 640w, /images/hero-crop-square.jpg?width=828 828w" sizes="100vw">
<source media="(min-width: 641px)" srcset="/images/hero.jpg?width=1280 1280w, /images/hero.jpg?width=1920 1920w" sizes="50vw">
<img src="/images/hero.jpg?width=1280" width="1920" height="1080" alt="Hero with different crop on mobile">
</picture>
I've found that the most impactful fix in this layer is adding explicit width and height to prevent layout shift, then writing an honest sizes value. If your CSS renders the image at 33vw on desktop but your sizes says 100vw, the browser will over-fetch by 3x. Audit this by enabling layout shift highlighting and comparing the downloaded resource width in DevTools to the rendered width. They should be within 20% after DPR is accounted for.
For LCP images, pair this markup with a preload. The preload scanner cannot always infer the correct srcset candidate from CSS, so a <link rel="preload" as="image" imagesrcset="..." imagesizes="..."> in the <head> ensures the highest-priority candidate is discovered immediately. This ties directly into your Resource Hints Strategy: Preload, Prefetch, Preconnect and DNS-Prefetch — preloading the hero image is often worth 200-400 ms on LCP.
Avoiding DPR Waste with Sensible Density Caps
Device pixel ratios of 3x and 4x exist, but serving 3x images rarely improves perceived quality at typical viewing distances while doubling bytes over 2x. In my experience, capping srcset selection at 2x DPR delivers the best quality-per-byte tradeoff. You can enforce this at the edge by ignoring extremely high DPR Client Hints or by not offering a 3x candidate in srcset. Users on 3x screens still get a sharp 2x image scaled slightly, which tests show is indistinguishable in blind comparisons for photographic content.
Configuring Edge Logic for Format Conversion, Resizing, and Perceptual Quality
At the edge, you need three primitives: format encoding, resizing, and quality control. Most CDN image products — Cloudflare Images, Akamai Image Manager, Fastly Image Optimizer, CloudFront + Lambda@Edge — expose these as URL parameters or worker logic that operates on the cached origin asset. The pattern is consistent: fetch the high-quality master from origin once, store it in tiered cache, then derive variants on demand and cache those derived responses at the edge PoP.
The quality setting deserves more care than most teams give it. AVIF at quality 50 and WebP at quality 75 often outperform JPEG at quality 82 on DSSIM, but the curve is content-dependent. Textured fabric and foliage tolerate lower AVIF quality better than flat gradients or skin tones. I recommend starting with AVIF q50, WebP q75, JPEG q82 as defaults, then running a visual regression on a 50-image representative set. Some platforms support perceptual quality targets like quality=auto:good which maps to a Butteraugli distance target rather than a fixed quantizer — that automation holds up well in production.
// Cloudflare Workers / Fastly Compute@Edge example: format negotiation + resizing
// Keeps canonical URL but varies on Accept and Width
addEventListener('fetch', event => {
event.respondWith(handleImage(event.request));
});
async function handleImage(request) {
const url = new URL(request.url);
const accept = request.headers.get('Accept') || '';
// 1. Normalize format preference to avoid cache fragmentation
let format = 'jpeg';
if (accept.includes('image/avif')) format = 'avif';
else if (accept.includes('image/webp')) format = 'webp';
// 2. Enforce allowlisted widths to bound cache cardinality
const allowedWidths = [640, 828, 1280, 1920];
let width = parseInt(url.searchParams.get('width') || '1280', 10);
width = allowedWidths.reduce((prev, curr) =>
Math.abs(curr - width) < Math.abs(prev - width) ? curr : prev
);
// 3. Fetch from image resizing service
const imageRequest = new Request(url.pathname, request);
const resized = await fetch(imageRequest, {
cf: {
image: {
format: format,
width: width,
quality: format === 'avif' ? 50 : format === 'webp' ? 75 : 82,
fit: 'scale-down'
},
cacheKey: `${url.pathname}-${format}-${width}`,
cacheTtl: 31536000
}
});
const response = new Response(resized.body, resized);
response.headers.set('Vary', 'Accept');
response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
response.headers.set('Content-Type', `image/${format}`);
return response;
}
Resizing must be scale-down or scale-down-to-fit semantics — never upscale a small master to a larger width. Upscaling wastes CPU and inflates bytes without adding detail. Store the highest-resolution master you have (ideally 2500-3000px wide at quality 90) and scale down from it. If your CMS already downscales aggressively on upload, fix that pipeline first.
For caching, I set immutable long-lived cache for versioned assets and shorter, revalidated cache for user-generated content. Use stale-while-revalidate for image variants so a cache miss on an uncommon width does not block rendering. And always strip metadata by default. EXIF orientation and ICC profiles can add 15-30 KB per image, and most browsers assume sRGB unless you explicitly need wide-gamut delivery.
Measuring Compression Gains, Cache Efficiency, and LCP in Production
Once the pipeline is live, validate savings with real data, not just lab encodings. I track three signals: byte savings per format, cache hit ratio per variant, and LCP attributable to images. The table below summarizes the ranges I consistently observe when moving from a baseline of JPEG quality 82 to edge-negotiated AVIF and WebP at perceptually equivalent settings.
| Format & Settings | Typical Savings vs. JPEG 82 | Decode Cost (Mobile Median) | Encoding Cost at Edge | Ideal Content Profile |
|---|---|---|---|---|
| AVIF q50 (libaom / rav1e) | 50–65% smaller | 1.8–2.5× JPEG decode time | High (6–10× JPEG) | Photos, high texture, large hero images |
| WebP q75 (libwebp) | 25–35% smaller | 1.2–1.5× JPEG decode time | Low (1.2× JPEG) | General purpose, safe fallback, UI graphics |
| JPEG q82 (mozjpeg) | Baseline | Baseline (fastest) | Baseline | Compatibility, email, non-browser contexts |
| PNG (lossless) | Often 3–5× larger than WebP lossless | 1.0–1.1× JPEG | Low | Logos with sharp edges, diagrams, screenshots |
For photographic content, AVIF wins on pure bytes, but its decode cost matters. On mid-range Android devices, AVIF decode can add 40-80 ms to LCP compared to WebP. In my experience, that is still a net win when the byte savings exceed 100 KB, because network time dominates on 4G. For thumbnails under 60 KB, the difference is noise and either format is fine. I prefer to default to AVIF for images larger than 50 KB and allow WebP for smaller tiles where decode time is proportionally higher.
Cache efficiency is where URL design pays off. If you allow arbitrary widths, your variant count explodes. After allowlisting six widths and three format buckets, a single logical image has at most 18 cache keys. In production I typically see a 92-96% edge hit ratio on images after the first hour of warming, versus 68% when arbitrary widths were permitted. Check your CDN's cache status headers and log the CDN-Cache-Status or X-Cache for a sample of image URLs to confirm.
Finally, tie this back to user experience. Segment your Real User Monitoring by format actually served — log the Content-Type of the LCP image — and compare LCP distributions. One media site I worked with saw LCP p75 drop from 2.8 s to 2.1 s after enabling AVIF + responsive resizing, with a 44% reduction in image bytes. That improvement unlocked headroom for Critical Rendering Path: Eliminating Render-Blocking Resources work that had been masked by image bloat.
Preventing Common Failure Modes in Production Image Pipelines
Three failure modes recur across implementations. First, double compression. If your origin already compresses JPEGs at quality 70 and the edge recompresses that artifacted source into AVIF, you bake in generational loss. Always keep a high-quality master at the origin and compress only at the edge from that master. If you must use origin-compressed sources, set the edge to pass-through without recompression when the source quality is already low.
Second, incorrect Vary handling with CDNs that strip Vary by default. Some configurations remove Vary to improve hit ratio, which breaks format negotiation. Verify with two sequential curl requests with different Accept headers and confirm you get different Content-Type responses and distinct cache keys. If your CDN offers a image-format cache key instead of raw Vary, use it — it is more efficient than caching on the full header value.
Third, CLS regressions from missing dimensions. Responsive images still need intrinsic size. If you use CSS like width: 100%; height: auto without width/height attributes, the browser cannot reserve space until the image downloads. Add width and height that reflect the aspect ratio of the master, then use aspect-ratio in CSS for fluid layouts. In my experience, this eliminates most image-induced CLS even when images load late.
Frequently Asked Questions
Should I use <picture> with explicit AVIF/WebP <source> tags or rely on Accept-based negotiation?
For most sites, Accept-based negotiation from a single canonical URL is simpler and more cache-friendly. Use <picture> only when you need art direction — different crops, focal points, or aspect ratios per breakpoint. If you already have a working negotiation layer at the edge, adding <picture> sources for every format duplicates logic, doubles your HTML payload, and makes future format upgrades harder to deploy.
How do I handle browsers that don't support AVIF or WebP?
Your edge logic should treat JPEG as the universal fallback. When the Accept header does not contain image/avif or image/webp, return the original JPEG or a mozjpeg-optimized variant. Because you send Vary: Accept and key the cache by format, fallback users get a cacheable JPEG variant while modern browsers get the newer format from the same URL. Test with curl -H "Accept: */*" to simulate legacy clients.
Is AVIF always smaller than WebP, and should I still generate both?
AVIF is usually 15-30% smaller than WebP at equivalent visual quality for photographs, but not universally. For very small icons, simple illustrations, or lossless cases, WebP can match or beat AVIF and it encodes and decodes faster. In practice I generate both: AVIF for eligible larger images, WebP as the efficient fallback. The storage and CPU cost is minimal because you generate variants on demand and cache them at the edge.
Can Client Hints replace srcset and sizes for responsive delivery?
Client Hints like Sec-CH-Width and Sec-CH-DPR can inform the edge about viewport width and DPR without complex markup, but they require opt-in via Accept-CH or Permissions-Policy, are not sent on the first request, and have privacy considerations. I treat them as a progressive enhancement for same-origin, navigational requests, not a replacement for srcset/sizes. Responsive markup remains the reliable, standards-based mechanism that works with preload scanners, caches, and all browsers.