Responsive Image Delivery

A 4000-pixel phone photo sent as-is to a 360-pixel feed card costs twenty times the bytes it needs, and the same photo as a single small JPEG looks soft on a desktop lightbox. Responsive delivery produces the right width and format for each request — without a separate URL scheme per client, without unbounded variant counts, and without layout jumping while it loads.

This topic is part of media processing and delivery pipelines. Upstream, post-upload media transcoding runs the workers that produce derivatives; downstream, secure media delivery decides who may fetch them and how long caches may keep them. Its video counterpart is adaptive bitrate video streaming, which solves the same “right bytes for this viewer” problem over time instead of over space.

Prerequisites

  • [ ] sharp 0.33+ on Node 20+ in the processing worker, installed for the deployment platform.
  • [ ] Originals stored privately, with a separate public prefix or hostname for variants.
  • [ ] A CDN or edge runtime you can program — Cloudflare Workers, CloudFront Functions or Lambda@Edge, Fastly Compute.
  • [ ] A list of the widths your layouts render images at, in CSS pixels.
  • [ ] Width and height (or aspect ratio) stored for every image, so markup can reserve space.
  • [ ] Optional: blurhash for placeholders and the ssimulacra2 tool for quality calibration.

How it works

Three decisions happen for every image request, and each has a place where it is best made.

Which width? The browser decides, from the srcset candidates and the sizes hint in the markup, before layout has even run. The server’s job is to make good candidates exist. A candidate list derived from real layout slots at 1× and 2× density, as in generating srcset variants at upload time, keeps overshoot small.

Which format? The browser advertises support in its Accept header; the edge chooses. AVIF where advertised, WebP next, JPEG for everything else — mapped to exactly three cache keys so the header’s endless variations never fragment the cache. Serving AVIF and WebP with Accept header negotiation implements it.

When are the bytes produced? At upload time (predictable, stored forever) or on first request at the edge (lazy, cached per data centre). The trade-off is covered in resizing images on the fly with a Cloudflare Worker; most platforms end up with a hybrid.

Who decides width, format and production time The browser chooses width from srcset and sizes. The edge chooses format from the Accept header and normalises the cache key. The processing worker or the edge resizer produces bytes, either at upload time or on first request, and stores or caches them. Three decisions, three places browser decides WIDTH srcset candidates sizes hint × DPR before layout runs edge decides FORMAT Accept → avif/webp/jpeg normalised cache key Vary: Accept worker / resizer decides WHEN at upload: stored on request: cached quality pinned per format The markup names widths; the URL never names a format. That keeps one string per width in your database, and lets you add a format later without touching a single template.
Width belongs to the client because only it knows the layout; format belongs to the edge because only it sees every client.

Two cross-cutting concerns tie these together. Layout stability: every <img> needs width and height (or a CSS aspect-ratio) from stored metadata so the browser reserves the box before bytes arrive; a placeholder can then fill that box, as generating BlurHash and LQIP placeholders shows. Consistent quality: the three formats must look the same, which means calibrating each encoder’s quality number against a perceptual target, as in choosing quality settings for AVIF, WebP and JPEG.

Step-by-step implementation

Step 1: Record dimensions and orientation at ingest

Everything else needs the displayed width, height and aspect ratio. Read them once, after applying EXIF orientation, and store them.

import sharp from "sharp";

export interface ImageFacts { width: number; height: number; aspect: number; hasAlpha: boolean; format: string }

export async function imageFacts(input: Buffer): Promise<ImageFacts> {
  const meta = await sharp(input, { limitInputPixels: 100_000_000 }).metadata();
  if (!meta.width || !meta.height || !meta.format) throw new Error("not a readable image");
  const swap = (meta.orientation ?? 1) >= 5;          // EXIF 5–8 rotate by 90°
  const width = swap ? meta.height : meta.width;
  const height = swap ? meta.width : meta.height;
  return { width, height, aspect: height / width, hasAlpha: Boolean(meta.hasAlpha), format: meta.format };
}

import { readFile } from "node:fs/promises";
console.log(await imageFacts(await readFile(process.argv[2])));
// { width: 3024, height: 4032, aspect: 1.3333333333333333, hasAlpha: false, format: 'jpeg' }

The limitInputPixels guard belongs here too — it is the first place an oversized image could exhaust memory. The same facts are what storing image dimensions and duration metadata persists for search.

Step 2: Produce the always-needed variants at upload time

Generate the two or three widths that every upload will certainly be shown at — typically the feed card and the detail view — in all three formats, plus placeholders. This keeps first-view latency low for the common case.

import sharp from "sharp";

const EAGER_WIDTHS = [320, 640, 1200];
const Q = { avif: 52, webp: 76, jpeg: 80 };

export async function eagerVariants(input: Buffer): Promise<Map<string, Buffer>> {
  const base = sharp(input, { limitInputPixels: 100_000_000 }).rotate().toColourspace("srgb");
  const { width = 0 } = await base.clone().metadata();
  const out = new Map<string, Buffer>();
  for (const w of EAGER_WIDTHS.filter((x) => x <= Math.max(width, EAGER_WIDTHS[0]))) {
    const r = base.clone().resize({ width: w, withoutEnlargement: true });
    out.set(`${w}.avif`, await r.clone().avif({ quality: Q.avif, effort: 4 }).toBuffer());
    out.set(`${w}.webp`, await r.clone().webp({ quality: Q.webp, effort: 4 }).toBuffer());
    out.set(`${w}.jpg`, await r.clone().jpeg({ quality: Q.jpeg, mozjpeg: true }).toBuffer());
  }
  return out;
}

import { readFile } from "node:fs/promises";
const v = await eagerVariants(await readFile(process.argv[2]));
for (const [k, b] of v) console.log(k.padEnd(10), `${Math.round(b.length / 1024)} KB`);
// 320.avif   9 KB
// 320.webp   13 KB
// 320.jpg    19 KB
// 640.avif   28 KB

Step 3: Serve other widths on demand at the edge

For anything outside the eager set — a lightbox at 2000 px, an email thumbnail at 160 px — resize on first request from a fixed list of presets, strip metadata, and cache for a year. Never accept arbitrary numeric parameters.

export const PRESETS = new Map<string, { width: number; height?: number; fit: "scale-down" | "cover" }>([
  ["w160", { width: 160, fit: "scale-down" }],
  ["w800", { width: 800, fit: "scale-down" }],
  ["w1600", { width: 1600, fit: "scale-down" }],
  ["w2000", { width: 2000, fit: "scale-down" }],
  ["sq96", { width: 96, height: 96, fit: "cover" }],
]);

export function parsePreset(pathname: string): { preset: string; key: string } | null {
  const m = pathname.match(/^\/([a-z0-9]+)\/([a-z0-9][a-z0-9/_-]{0,200}\.(?:jpe?g|png|webp|avif))$/i);
  if (!m || !PRESETS.has(m[1]) || m[2].includes("..")) return null;
  return { preset: m[1], key: m[2] };
}

console.log(parsePreset("/w800/photos/9c1f.jpg"), parsePreset("/w801/photos/9c1f.jpg"));
// { preset: 'w800', key: 'photos/9c1f.jpg' } null

The full Worker around this parser is in resizing images on the fly with a Cloudflare Worker.

Step 4: Negotiate format on a normalised key

At the edge, turn the Accept header into one of three values and use that value — not the header — in the cache key.

export function formatBucket(accept: string | null): "avif" | "webp" | "jpeg" {
  const a = accept ?? "";
  return a.includes("image/avif") ? "avif" : a.includes("image/webp") ? "webp" : "jpeg";
}

export function cacheKey(origin: string, assetId: string, width: number, accept: string | null): string {
  return `${origin}/img/${assetId}/${width}#${formatBucket(accept)}`;
}

console.log(cacheKey("https://img.example.com", "9c1f", 640,
  "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"));
// https://img.example.com/img/9c1f/640#avif

Step 5: Emit markup that reserves space and hints size

The component, not the data, knows the slot width. Build srcset from the widths that exist and let each component pass its own sizes.

interface Asset { id: string; width: number; height: number; widths: number[]; blurhash?: string }

export function renderImg(a: Asset, sizes: string, alt: string, eager = false): string {
  const base = "https://img.example.com/img";
  const srcset = a.widths.map((w) => `${base}/${a.id}/${w} ${w}w`).join(", ");
  const fallback = a.widths.find((w) => w >= 640) ?? a.widths[a.widths.length - 1];
  const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
  return `<img src="${base}/${a.id}/${fallback}" srcset="${srcset}" sizes="${esc(sizes)}" ` +
    `width="${a.width}" height="${a.height}" alt="${esc(alt)}" ` +
    `${eager ? 'fetchpriority="high"' : 'loading="lazy" decoding="async"'}` +
    `${a.blurhash ? ` data-blurhash="${esc(a.blurhash)}"` : ""}>`;
}

console.log(renderImg({ id: "9c1f", width: 3024, height: 4032, widths: [320, 640, 1200] },
  "(min-width: 1024px) 400px, calc(100vw - 32px)", "Red bicycle against a brick wall"));

loading="lazy" for everything below the fold and fetchpriority="high" for the single largest above-the-fold image are the two attributes that most affect Largest Contentful Paint on image-heavy pages.

Bytes delivered for a 30-card feed under four strategies Serving originals transfers about 96 megabytes; a single 1200 pixel JPEG about 5.4 megabytes; srcset with JPEG about 1.9 megabytes; srcset with AVIF negotiation about 0.95 megabytes. 30-card feed on a 390 px, 3× phone original uploads ≈ 96 MB one 1200 px JPEG 5.4 MB srcset, JPEG 1.9 MB srcset + AVIF 0.95 MB Right-sizing is worth 50×; format negotiation is worth a further 2×. Do them in that order. (Originals scaled to fit — the first bar would be about 100 times wider at the same scale.)
Width selection dwarfs every other optimisation; format negotiation is the next largest, and both are automatic once built.

Upload-time, on-demand, or both

The single largest design decision in this topic is when variants are produced, because it fixes your storage bill, your first-view latency and how painful the next layout change will be.

Upload-time generation produces every width and format while the uploader waits for processing to finish. Its strengths are predictability — every variant exists before the image is first shown, so the first viewer gets a cache-friendly static object — and simplicity at the edge, which only has to negotiate format and serve files. Its weaknesses are cost and rigidity. Twenty-four files per upload is normal, most of which nobody will ever request, and adding a width means a back-fill job over every existing image.

On-demand generation stores only the original and produces a variant the first time a data centre sees a request for it. Storage stays close to one copy per upload, new layouts cost nothing until they are used, and rarely viewed uploads never pay for variants at all. The costs move to the request path: the first viewer in each region waits a few hundred milliseconds, and the transformation service is billed per unique variant, which is why the presets must be bounded.

The hybrid — eager generation of the two or three widths that every upload will certainly need, on-demand for everything else — is what most mature platforms converge on. The feed card and the detail view are always fast; the lightbox, the email digest and next quarter’s new layout are handled lazily.

A useful way to decide the eager set is to look at logs rather than designs. Group image requests by width over a month and sort by count: the top two or three widths usually account for more than 80% of requests, and those are your eager widths. Anything below a few percent belongs on demand. Revisit the split when the design system changes, not on a schedule.

Whatever the split, keep the variant key scheme identical for both paths — img/<assetId>/<width> — so the markup never knows or cares how a given width came to exist. That is what lets you move a width from eager to on-demand (or back) with a configuration change and no template edits.

Operating the pipeline

Three numbers tell you whether the system is healthy, and all three come from edge logs you already have.

Bytes per image view. Divide total image egress by image requests, per page type. A sudden rise means a sizes attribute went missing or a new component shipped without srcset — the browser falls back to the largest candidate or the src.

Edge hit rate per preset. A preset with a low hit rate either has too little traffic to stay warm (a candidate for removal) or is being requested with varying keys (a normalisation bug). Rates below about 80% on a popular preset deserve investigation.

Transformations per day. For on-demand resizing, this is the bill. It should track new uploads and new layouts; if it tracks total traffic instead, something is defeating the cache — commonly a query-string cache-buster appended by a front-end build.

Alert on the first two in the same dashboard as page performance, because they explain most regressions in Largest Contentful Paint on image-heavy pages. A drop in hit rate with no change in traffic almost always means someone changed the cache key.

Configuration reference

Setting Type Default here Effect
Width ladder px list 160–2000, 8 rungs Candidates in srcset; derive from layout slots × 1–2 DPR.
Eager widths px list 320, 640, 1200 Generated at upload; everything else on demand.
sizes media list per component Tells the browser the slot width before layout; omitting it means 100vw.
AVIF quality 0–100 52 (effort 4) Calibrated to match JPEG 80 perceptually on photos.
WebP quality 0–100 76 Same target, different scale.
JPEG quality 0–100 80, mozjpeg The reference everything else is matched against.
limitInputPixels integer 100 000 000 Refuse images above 100 MP before decoding.
Metadata policy stripped EXIF (including GPS) never reaches a public variant.
Cache key string URL + format bucket Three entries per width, never the raw Accept.
Cache-Control header max-age=31536000, immutable Safe because variants are never overwritten in place.
loading attribute lazy below the fold Defers off-screen images; never on the LCP image.

Edge cases and gotchas

EXIF orientation

Phones store landscape pixels plus an orientation tag. Every tool in the chain must apply it — Sharp with .rotate(), edge resizers do automatically — and every stored width and height must be post-rotation. A portrait photo stored as 4032×3024 produces landscape placeholders and wrong height attributes, which is a layout shift on every page it appears in.

Wide-gamut colour

iPhone photos use Display P3. Converting to sRGB with .toColourspace("srgb") before encoding keeps colours consistent across browsers that ignore embedded profiles; stripping the profile without converting makes images look washed out.

Animated uploads

Animated GIF and WebP uploads either need animated variants (large, slow to encode) or a first-frame still plus a separate video rendition. Converting animations to a short MP4 is usually 5–10× smaller than an animated WebP and plays everywhere; render it with <video autoplay muted loop playsinline>.

Transparency

PNG logos and stickers need alpha preserved: AVIF and WebP both support it, JPEG does not. Route images with hasAlpha to a PNG fallback instead of JPEG, and skip BlurHash placeholders for them.

Alt text for user uploads

Responsive markup is not finished without an alt attribute, and for user uploads you rarely have a good one. Ask for a short description at upload time where the product allows it, store it on the asset, and fall back to a neutral description built from context (“Photo shared by Priya, 3 March”) rather than the filename. Never emit alt="IMG_4032.HEIC"; an empty alt="" is better for purely decorative images, and the uploader’s own caption is better still. The upload form side of this is covered in accessible upload interfaces.

Cache fragmentation

Anything that varies the cache key multiplies variants: raw Accept, query-string ordering, ?v= cache-busters that change per deploy. Normalise the key to exactly what changes the bytes — asset, width, format bucket — and nothing else.

Edge cache hit rate by cache key strategy Keying on the raw Accept header yields about a 41 percent hit rate. Keying on URL only yields 96 percent but serves the wrong format to some browsers. Keying on URL plus a normalised format bucket yields about 93 percent with correct formats. Edge hit rate over a week, same traffic raw Accept in key 41% — fragmented URL only 96% — but wrong format for some URL + format bucket 93% — correct formats Three entries per width costs three points of hit rate and buys format correctness for every browser; the raw header costs fifty-five points and buys nothing.
Normalising the key before caching is the difference between negotiation that pays for itself and negotiation that doubles origin load.

Verification

# Width candidates exist and nothing is wider than the original.
aws s3 ls "s3://$MEDIA_BUCKET/img/9c1f2a7e/" | awk '{print $4}' | sort -V

# Format negotiation returns three distinct types and Vary.
for a in 'image/avif,image/webp,*/*' 'image/webp,*/*' '*/*'; do
  curl -s -o /dev/null -D - -H "Accept: $a" https://img.example.com/img/9c1f2a7e/640 \
    | grep -Ei '^(content-type|vary):' | tr '\n' ' '; echo
done

# No GPS or camera metadata in any public variant.
curl -s https://img.example.com/img/9c1f2a7e/640 | exiftool -GPS:all -Make -Model -

In the browser, run Lighthouse on a feed page: “Properly size images” and “Serve images in modern formats” should both pass, and CLS for the image grid should be zero thanks to the width and height attributes.

Frequently Asked Questions

Should I use an image CDN instead of building this?

An image CDN (Cloudflare Images, imgix, Cloudinary and similar) packages exactly these decisions — presets, negotiation, caching, quality — as a service, and is a sensible choice if the per-image or per-transformation price fits. The design principles here still apply: bounded presets, normalised format keys, stripped metadata, and reserved layout space are what make any image CDN efficient.

How many widths should the ladder have?

Enough that the browser’s pick is rarely more than about 25% larger than the slot needs, and no more. Six to eight widths between 160 and 2000 px covers most layouts; each extra rung adds storage or cold misses for a shrinking gain.

Do I still need <picture>?

Only for art direction (different crops per breakpoint) or for a static set of hero images where you would rather not run an edge function. For user uploads, one <img> with srcset, sizes and a negotiated URL does everything <picture> would, with less markup and one URL per width.

Where should resizing happen for the uploader’s own preview?

In the browser, before upload. A local preview from an object URL appears instantly and costs nothing; shrinking the file first with resizing images in the browser with canvas also cuts upload time on mobile.