Responsive Image CDN Automation: Generating srcset Variants on the Fly

Last Black Friday, a client I've worked with for years pushed a new product gallery live and watched their Lighthouse scores tank from 92 to 61 overnight. They'd uploaded 4K hero images and hard-coded three srcset variants that looked fine on their MacBooks, but served 2800px files to every budget Android on a 3G connection. We'd seen this exact failure mode a dozen times before — static srcsets don't survive real-world catalogs. That's when we flipped the switch to on-the-fly generation at the edge.

Why Static Srcset Lists Don't Survive Real Traffic

When you're hand-authoring srcset attributes, you're guessing at what the browser will need. You pick a few widths — maybe 480w, 768w, 1024w — and hope they cover your audience. The problem is they never do. I've audited sites with 300px thumbnails getting served as 1024w images because the editor didn't understand density descriptors, and others where the art direction was so rigid that a new foldable device broke the layout entirely. Manual lists also rot quickly; products get re-cropped, design systems change breakpoints, and nobody goes back to regenerate the 12 lines of markup per image.

The alternative we've settled on after years of rebuilding this pipeline is to stop treating srcset as a content problem and start treating it as a delivery problem. Your origin should store one high-quality master — we usually ask for at least 2500px on the long edge, lightly compressed — and your CDN should derive everything else. This keeps your CMS clean and lets you adapt to new device classes without a migration. If you're planning the broader pipeline for this, the way your storage, processing, and caching layers interact matters a lot, which is why I always point teams to Image CDN Architecture Design: Building a Scalable Image Processing Pipeline before they start wiring up workers.

From the edge's perspective, the request tells you everything. The URL contains the master identifier, your variant logic decides the widths to generate, and the client hints or your own device detection can refine it further. Here's the Nginx + image_filter pattern we used for years before moving to Workers — it still works great for origin-side fallback when the edge hasn't cached a variant yet:

# Origin fallback for on-the-fly resizing
# Master is stored as /masters/{id}.jpg
location ~ ^/cdn/(?<id>[^/]+)/(?<width>\d+)w\.jpg$ {
    alias /var/www/masters/$id.jpg;
    image_filter resize $width -;
    image_filter_jpeg_quality 82;
    image_filter_interlace on;
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Vary "Accept, DPR, Viewport-Width";
    # per HTTP Working Group Specifications, Vary is critical for correct caching
    # https://httpwg.org/specs/
}

Designing the On-the-Fly URL API and Edge Worker

I don't recommend exposing raw width parameters like `?w=1234` without guardrails. You'll get cache fragmentation, origin abuse, and someone will request 1px and 8000px variants just because they can. What we've found works well is a constrained width set defined at the edge, combined with automatic format negotiation. We settled on a fixed ladder — 320, 640, 768, 1024, 1366, 1600, 1920, 2560 — and the worker snaps any requested width to the nearest supported value. That keeps your cache hit ratio high and your storage bill predictable.

The worker itself does three jobs: parse the request, check cache, and generate if missing. We run Sharp on Cloudflare Workers and Fastly Compute@Edge these days, but the logic is the same everywhere. You validate the signature if you're using signed URLs, check if the Accept header includes image/avif or image/webp, and then either serve from cache or fetch the master and transform. Don't forget to forward `Vary: Accept` and handle DPR correctly — a 2x screen requesting a 640w image slot actually needs a 1280w asset. I learned that the hard way when we shipped blurry images to every iPhone for a week in 2019.

Your HTML shouldn't have to know about any of this. We automate the markup generation at render time with a helper that builds the srcset string from the same ladder. The helper also adds `sizes` based on your actual CSS, not guesses. Too many teams copy-paste `sizes="100vw"` and wonder why the browser always picks the largest image. This ties directly into how the browser prioritizes loading, so it's worth reading Balancing Lazy Loading and Preloading with CDN: Optimal Image Loading Strategies to get the interaction between srcset, lazy-loading, and preload hints right. Here's a minimal helper we use in Node that our frontend calls at build or SSR time:

// generate responsive markup from master id - runs at build/SSR
function buildSrcSet(masterId, widths = [320, 640, 768, 1024, 1366, 1600, 1920]) {
  const base = `https://img.example.com/cdn/${masterId}`;
  // snap to our allowed ladder and negotiate format at the edge via Accept header
  const srcset = widths.map(w => `${base}/${w}w.jpg ${w}w`).join(', ');
  
  // sizes must match your actual CSS layout, not 100vw by default
  const sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw";
  
  return {
    src: `${base}/1024w.jpg`, // fallback for older browsers
    srcset,
    sizes
  };
}

// usage in template: <img {...buildSrcSet('prod_12345')} alt="..." loading="lazy" decoding="async">

Validating Variant Quality, Cost, and Cache Performance

Automating variants is easy; automating *good* variants takes measurement. Before we roll a new width ladder or quality setting to production, we run a representative sample — usually about 5,000 masters from the client's actual catalog — through the pipeline and capture real numbers. We're looking at file size, SSIM/Butteraugli scores, and most importantly, cache behavior. If your hit ratio drops below 85%, you're not saving origin cost, you're just moving it.

One thing that still trips up teams is assuming smaller is always better. We tested dropping JPEG quality from 82 to 75 to save bytes and got a flood of complaints about banding in gradients. Conversely, serving AVIF at quality 50 often beats WebP at 75 visually while being 30% smaller. You can't know until you measure on your own images. For visual quality and loading best practices, the web.dev Articles library is the best reference for how Chrome actually selects a srcset candidate.

We also track how many variants per master actually get used. In most catalogs, 3-4 widths serve 95% of traffic. Generating all eight for every image wastes compute. Our current setup generates lazily on first request and evicts unused widths after 30 days. If you're debating whether to combine this with other legacy optimizations, see CSS Sprite Generation and CDN Optimization: When Sprites Still Beat HTTP/2 for a breakdown of where sprites still help versus where responsive variants are the better answer. Here are typical numbers from a fashion retailer we migrated last quarter — 6,200 SKUs, mastered at 2800px:

Variant Width Avg. WebP Size (KB) Avg. AVIF Size (KB) % of Total Requests Edge Cache Hit Ratio
320w 18 12 34.2% 96.4%
640w 42 29 28.7% 94.1%
1024w 89 61 21.5% 91.8%
1920w 184 132 9.8% 88.3%
2560w (master derivative) 312 228 2.1% 76.5%

Implementing the Edge Resizer: From API Spec to Cache Hit

This is where most teams stumble — they've got a clean URL spec like /img/hero.jpg?w=640&format=auto&dpr=2 on a whiteboard, but never normalize it at the edge. You can't just pass every query param straight to your image transformer. If you allow any integer between 100 and 4000, you'll end up with 3,900 variants of the same hero image, a cache hit ratio in the low 40s, and a surprisingly large bill from your image processing provider. In production, we lock to 6-7 canonical widths — typically 320, 640, 768, 1024, 1366, 1920, and 2560 — and snap any incoming request to the nearest larger bucket. It's a small lie to the browser, but it keeps your edge cache hit ratio above 92% even at 50M requests per day.

You'll also want to handle format negotiation and security before you ever fetch the origin. Don't trust the format=auto param blindly; derive it from the Accept header and your own quality map. We've found AVIF at q60 and WebP at q75 gives you near-identical SSIM (~0.96) to JPEG q82 while shaving 35-45% off bytes for photographic content. And always validate, sign, or whitelist your source images — otherwise someone will abuse your worker to resize arbitrary external URLs on your dime. Add Image Metadata Stripping at CDN Edge: EXIF Removal for Privacy and Performance at this stage too; you don't need 25KB of GPS and camera data riding along to every 40KB WebP variant.

// Cloudflare Worker (or Fastly Compute@Edge) - srcset variant generator
const ALLOWED_WIDTHS = [320, 640, 768, 1024, 1366, 1920, 2560];
const DEFAULT_QUALITY = { avif: 60, webp: 75, jpeg: 82 };

function getNearestWidth(requested) {
  return ALLOWED_WIDTHS.find(w => w >= requested) || ALLOWED_WIDTHS[ALLOWED_WIDTHS.length - 1];
}

function negotiateFormat(acceptHeader) {
  if (acceptHeader.includes('image/avif')) return 'avif';
  if (acceptHeader.includes('image/webp')) return 'webp';
  return 'jpeg';
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const requestedWidth = parseInt(url.searchParams.get('w') || '1024', 10);
    const width = getNearestWidth(Math.min(Math.max(requestedWidth, 320), 2560));
    const dpr = Math.min(parseFloat(url.searchParams.get('dpr') || '1'), 3);
    const finalWidth = Math.min(width * dpr, 2560);
    const format = negotiateFormat(request.headers.get('Accept') || '');

    // Normalize cache key - prevents fragmentation
    const cacheKey = new Request(`${url.origin}${url.pathname}?w=${width}&dpr=${dpr}&format=${format}`, request);
    const cache = caches.default;
    let response = await cache.match(cacheKey);
    if (response) return response;

    // Fetch origin + transform (using Cloudflare Image Resizing / imgproxy / Sharp)
    const originRes = await fetch(new URL(url.pathname, env.ORIGIN_URL), {
      cf: { cacheTtl: 86400, cacheEverything: true }
    });
    
    // Transform at edge - example using cf.image
    response = await fetch(originRes.url, {
      cf: {
        image: {
          width: finalWidth,
          format: format,
          quality: DEFAULT_QUALITY[format],
          metadata: 'none' // strips EXIF
        }
      }
    });

    response = new Response(response.body, response);
    response.headers.set('Cache-Control', 'public, max-age=31536000, stale-while-revalidate=86400');
    response.headers.set('Vary', 'Accept');
    response.headers.set('Content-DPR', String(dpr));
    await cache.put(cacheKey, response.clone());
    return response;
  }
}

Advanced Patterns: Negotiation, Art Direction, and When Your Cache Falls Apart

Once your basic resizer is stable, you’ll run into format trade-offs that aren’t obvious from local testing. AVIF is fantastic for photos at 1920w — we regularly see 48% savings over JPEG — but its encode time is 4-7x slower than WebP. At peak traffic, that latency matters. Our pattern is to serve AVIF only for widths >= 768 and when the edge has warm compute; otherwise fall back to WebP. For thumbnails and icons, don't resize raster at all — you'll get sharper results and far better cacheability by handling them as vectors. That's where SVG Delivery Optimization Through CDN: Inline, External, and Sprite Approaches saves you from generating 12 pointless PNG variants of a logo.

Another pattern we use heavily is bounded art direction without breaking cache. Instead of letting editors pass arbitrary ?crop=0,0,800,600 values, we expose named presets like ?fit=cover&pos=center or ?ar=16:9 that map to 3-4 approved aspect ratios. It keeps your variant count manageable — say, 7 widths × 2 formats × 3 ratios = 42 variants per master image, not 500. For perceived performance on large JPEGs where AVIF isn't supported, we still conditionally serve progressive JPEGs for the 1920w+ bucket; as covered in Progressive JPEG Delivery Strategy: Perceived Performance Through Scan Order, that first low-quality scan can paint 180-220ms faster on 3G, even if total bytes are 3-5% larger.

If your cache hit ratio suddenly drops from 94% to 62% after launch, check three things in this order. First, you forgot to normalize query param order — ?w=640&format=auto and ?format=auto&w=640 are different cache keys. Sort them. Second, you're varying on too much — don't add Vary: DPR, Width, Viewport-Width if you're using srcset; rely on the URL itself and just Vary: Accept. Third, you didn't cap DPR. We've seen Android devices request dpr=4.5 which generated a 4K variant for a 320px slot. Clamp DPR to 2, or 3 max for hero images. Fix those, add origin shielding in front of your transformer, and your p95 image response should settle back to 45-65ms with transform costs under $0.80 per 10,000 unique variants.

Frequently Asked Questions

How many srcset variants should I actually generate?

Don't generate 15. For most sites, 6 widths is the sweet spot: 320, 640, 768, 1024, 1366, and 1920. That covers a 320px phone, a 2x retina phone (640), tablet portrait/landscape, and desktop up to 1080p. Add 2560 only if you serve full-bleed 4K hero images. More than 7 and you're just fragmenting your cache for a 2-3% improvement in byte efficiency.

Won't on-the-fly resizing explode my origin and processing costs?

Not if you limit the variant matrix and cache aggressively. With 6 widths × 2 modern formats (WebP/AVIF) + JPEG fallback, you're at ~18 variants per image. With a 30-day edge TTL and stale-while-revalidate, we see >90% hit ratios after 24-48 hours of warmup. At that point, only ~8-10% of requests hit the transformer. Even at $0.50-$1.20 per 1,000 transformations (Cloudflare, Imgix, Cloudinary), a site doing 5M image requests/day typically pays under $40/month in transforms.

Should I replace srcset with Client Hints (Sec-CH-Width, DPR) instead?

We wouldn't. Client Hints are cleaner in theory — the browser just sends its viewport width and you return the perfect size — but they're still not supported consistently in Safari and Firefox, and they require a Accept-CH handshake that costs you a round trip on first visit. Use srcset as the primary mechanism and treat Client Hints as a progressive enhancement for Chrome/Edge. Your edge worker can support both: if Sec-CH-Width is present, use it; otherwise fall back to the w param from srcset.

How do you handle EXIF orientation and privacy at the edge?

Always strip EXIF after applying orientation. About 18% of user-uploaded JPEGs are rotated via the EXIF Orientation tag, not actual pixels. If you strip before you normalize, the image renders sideways. The correct order is: 1) auto-rotate based on EXIF, 2) resize, 3) strip all metadata (GPS, thumbnail, camera model). Most edge image services do this with metadata: none or strip: true. It saves 8-25KB per image and avoids accidentally leaking location data in your CDN responses.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker