Fingerprinting Responsive Image srcsets
The symptom is oddly specific: the new photograph appears on a phone but the old one is still there on a laptop, or vice versa, and a hard refresh fixes nothing. That is not a caching bug — it is four or twelve separate files where you were thinking about one, and only some of them were regenerated.
A srcset is a list of independent URLs. The browser picks exactly one, based on viewport width, device pixel ratio, and the sizes hint. Each candidate is fetched, cached, and revalidated on its own. If your build regenerates the 480w and 960w AVIF but reuses a stale 1440w, users on wide displays see the old image indefinitely, and no amount of purging the “hero image” fixes it because there is no such thing as the hero image.
Every Candidate Is a Separate Cache Entry
The practical consequence is that the unit of correctness is the variant set, not the image. A deploy either regenerates every candidate for a source file or none of them, and the markup either names all the fresh hashes or none. Anything in between produces a split-brain image where the version a user sees depends on their screen.
Generating the Set and the Markup Together
The safest arrangement is a single build step that owns both halves: it encodes the variants and emits the markup. If markup generation is a separate hand-maintained template, the two drift the first time someone adds a width.
Given a variants.json produced by the encoder — a map from base name to a list of { width, ext, file } records — this helper renders a complete <picture> element with every URL fingerprinted:
// scripts/picture.mjs
import { readFileSync } from 'node:fs';
const manifest = JSON.parse(
readFileSync('src/generated/images/variants.json', 'utf8')
);
const BASE = '/static/img/';
const MIME = { avif: 'image/avif', webp: 'image/webp', jpg: 'image/jpeg' };
// Most-preferred format first: the browser takes the first source it supports.
const ORDER = ['avif', 'webp', 'jpg'];
export function picture(name, { sizes, alt, loading = 'lazy' }) {
const set = manifest[name];
if (!set) throw new Error(`no generated variants for image "${name}"`);
const byExt = (ext) =>
set
.filter((v) => v.ext === ext)
.sort((a, b) => a.width - b.width)
.map((v) => `${BASE}${v.file} ${v.width}w`)
.join(', ');
const sources = ORDER.filter((ext) => ext !== 'jpg')
.map((ext) => {
const srcset = byExt(ext);
if (!srcset) return '';
return `<source type="${MIME[ext]}" srcset="${srcset}" sizes="${sizes}">`;
})
.filter(Boolean);
const jpgs = set.filter((v) => v.ext === 'jpg').sort((a, b) => a.width - b.width);
if (jpgs.length === 0) throw new Error(`image "${name}" has no jpeg fallback`);
const widest = jpgs[jpgs.length - 1];
const img =
`<img src="${BASE}${widest.file}" srcset="${byExt('jpg')}" sizes="${sizes}" ` +
`width="${widest.width}" alt="${alt}" loading="${loading}" decoding="async">`;
return `<picture>${sources.join('')}${img}</picture>`;
}
Two details in that function are load-bearing. The throw when a variant set is missing turns a silent stale-image bug into a build failure — without it, a typo in the image name renders an empty <picture> and nobody notices for a week. And the fallback src is chosen from the same manifest as the srcset, so it can never point at a hash from a previous build.
Keeping sizes honest
sizes is a promise to the browser about how wide the image will be laid out. It is evaluated before CSS is applied, so it cannot be inferred from your stylesheet — it has to be written by hand and kept in sync with the layout. A wrong sizes does not break caching, but it makes the fingerprinting work pointless: the browser downloads a 1440w file to display it at 320 CSS pixels, or worse, picks the 480w candidate for a full-bleed hero and ships a blurry image.
Pass sizes explicitly per call site rather than defaulting it. A hero and a thumbnail in the same component tree have nothing in common:
<!-- hero: full width up to 1200px, then capped -->
<picture>
<source type="image/avif"
srcset="/static/img/hero-480.a91c3f04.avif 480w,
/static/img/hero-960.c40de8b2.avif 960w,
/static/img/hero-1440.31fa08de.avif 1440w"
sizes="(max-width: 1200px) 100vw, 1200px">
<source type="image/webp"
srcset="/static/img/hero-480.5b2e77a1.webp 480w,
/static/img/hero-960.0e1d47ac.webp 960w,
/static/img/hero-1440.9b62c0f3.webp 1440w"
sizes="(max-width: 1200px) 100vw, 1200px">
<img src="/static/img/hero-1440.7f13a9dd.jpg"
srcset="/static/img/hero-480.2c9a5db1.jpg 480w,
/static/img/hero-960.6ad38e70.jpg 960w,
/static/img/hero-1440.7f13a9dd.jpg 1440w"
sizes="(max-width: 1200px) 100vw, 1200px"
width="1440" alt="Product photograph" decoding="async">
</picture>
Every URL in that block carries an 8-character hash, which is what makes the whole element safe to cache with immutable. Twelve or sixteen characters is the right call once a single bucket holds variants for thousands of source images across a monorepo.
Width descriptors, density descriptors, and the fallback trap
srcset accepts two descriptor styles and they are not interchangeable. Width descriptors (480w) require a sizes attribute and let the browser solve for the best candidate at the current layout width and pixel ratio. Density descriptors (2x) describe a fixed-size image at different pixel ratios and must not be combined with sizes — a browser that sees both ignores the density set. Mixing the two in one srcset is a parse error for the whole attribute, at which point the browser silently falls back to src and every user gets the fallback file regardless of screen size. That failure is invisible in a hashed-URL audit because the fallback is a legitimate 200 response.
The fallback src deserves its own attention. Older browsers and every crawler, link unfurler, and email client that renders your markup read src and nothing else. If the generator emits fresh hashes into srcset but leaves src pointing at a hand-typed path, social previews and search snippets keep showing the previous image long after the site itself has updated. Derive src from the same manifest entry as the widest fallback candidate, as the helper above does, and never write it literally.
Art direction — a genuinely different crop for narrow viewports — belongs in <source media="...">, not in srcset. Each media branch has its own complete variant set, so a page with a portrait mobile crop and a landscape desktop crop has two variant sets and roughly twice the files. That is the correct cost of art direction; the mistake is trying to fake it by pointing one srcset at crops of different aspect ratios, which makes the browser’s width-based selection produce an arbitrary crop.
Format Negotiation Versus Vary: Accept
There are two ways to serve AVIF to browsers that support it. They have very different cache behaviour.
| Property | <picture> with hashed sources |
One URL + Vary: Accept |
|---|---|---|
| Cache key | Full URL path, one entry per file | Path plus the Accept header value |
| Edge entries per image | Exactly the number of variants you built | Unbounded — one per distinct Accept string |
immutable safe |
Yes, the URL implies the bytes | No, the same URL serves different bytes |
| Markup size | Grows with formats × widths | Constant, one src |
| Adding a format | Rebuild and redeploy the markup | Server-side change only, no markup edit |
| Debuggability | The URL tells you exactly what was served | Requires reproducing the exact Accept header |
| Works on a dumb origin | Yes, static files only | No, needs negotiation logic at origin or edge |
The Accept header is the problem. Browsers do not send one canonical string — they send long, version-specific lists with quality factors, and those strings change between releases. A CDN that keys on the raw header ends up with dozens of cache entries per image, most of them holding identical bytes. Some CDNs mitigate this by normalising Accept into a small number of buckets before it reaches the cache key, but that normalisation is a configuration you own and can get wrong. The general shape of that failure is covered under Vary header pitfalls.
The decisive argument for explicit sources is immutable. A URL that can return AVIF bytes to one client and JPEG bytes to another cannot honestly be marked immutable, so you lose the caching directive that makes the whole fingerprinting exercise worthwhile — the one described in immutable and TTL tuning.
CDN Resizing Moves the Fingerprint, It Does Not Remove It
On-the-fly image services — resize and re-encode at the edge from a single master — look like they abolish the variant-set problem. They do not. They relocate the fingerprint from the path into the query string, where it is far easier to lose.
Three things go wrong with the query-string form. Many CDNs default to ignoring or sorting query strings on static extensions, which collapses every width into one entry. If the service caches on w and format but you forget to add a version parameter, re-uploading the master under the same path serves the old derivative until the derivative TTL expires. And parameter order matters on some caches, so ?w=960&v=c40de8b2 and ?v=c40de8b2&w=960 occupy two entries holding identical bytes.
If you use a resize service, make the cache key explicit and include a content-derived version parameter:
# Origin in front of an image resizing service.
# Cache on path + the exact parameters that determine the bytes, in a fixed order.
map $arg_v $img_version { default "none"; "~^[0-9a-f]{8}$" $arg_v; }
location /img/ {
proxy_cache images;
proxy_cache_key "$uri|w=$arg_w|fmt=$arg_fmt|v=$img_version";
proxy_cache_valid 200 365d;
proxy_ignore_headers Set-Cookie;
# Refuse to serve, and refuse to cache, a request with no version parameter.
if ($img_version = "none") { return 400; }
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Img-Key "$uri|w=$arg_w|fmt=$arg_fmt|v=$img_version" always;
proxy_pass http://image-resizer;
}
Returning 400 on a missing v is deliberate: it converts “someone hand-wrote an image URL without a version” from a year-long stale-cache incident into an immediately visible broken image in review. The X-Img-Key header exists so you can see the computed key in a response and confirm the CDN is not silently normalising it away. How cache keys are assembled in general is covered in cache key architecture.
Verification
One command proves the whole set is coherent: pull every candidate URL out of the rendered HTML, fetch each one, and confirm it exists, has the expected type, and is marked immutable.
# Extract every srcset/src URL from a rendered page and check each candidate.
curl -s https://example.com/products/widget/ \
| grep -oE '/static/img/[A-Za-z0-9._-]+\.(avif|webp|jpg)' \
| sort -u \
| while read -r path; do
read -r code type cc < <(
curl -sI "https://example.com${path}" \
| awk 'BEGIN{c="";t="";k=""}
/^HTTP/{c=$2}
tolower($0) ~ /^content-type:/{t=$2}
tolower($0) ~ /^cache-control:/{k=$0}
END{print c, t, (k ~ /immutable/ ? "immutable" : "NOT-IMMUTABLE")}'
)
printf '%-6s %-14s %-14s %s\n' "$code" "$type" "$cc" "$path"
done
Anything that is not 200, or that reports NOT-IMMUTABLE, is a candidate the pipeline did not fully own. A 404 means the markup names a hash the deploy did not upload — usually a partially regenerated variant set.
To find out which candidate a specific browser actually chose, open DevTools, disable the cache, reload, and read the Network panel’s initiator column — the chosen URL is the only one fetched. Comparing that URL against the sizes value you intended is the fastest way to confirm the layout hint is honest. Chrome’s element inspector also reports currentSrc on the image node, which is the resolved candidate after selection and is the value worth logging in a synthetic check.
When to Reconsider
Use a resize service instead of build-time variants when the images are user-generated or come from a CMS, so there is no build step that can see them. In that world you are hashing at upload anyway, and the resize service’s version parameter is simply where that hash lives.
Use Vary: Accept instead of explicit sources when the markup is not under your control — a third-party embed, an email, or a legacy template you cannot regenerate. Accept the cache fragmentation, drop immutable, and set a moderate TTL instead.
Drop the variant set entirely when the image is small enough that the largest candidate is under about 20 KB. Three encodes of a 15 KB illustration cost more in build time, deploy size, and markup weight than the bytes they save, and a single hashed WebP with a JPEG fallback is the better trade.
Related
- Hashing images, fonts, and media — parent guide covering which assets a bundler can hash
- Web fonts and
@font-facerewriting — the same rewriting problem inside CSS - Vary header pitfalls — why
Vary: Acceptfragments an image cache - Vite asset pipeline configuration — wiring a variant generator into the build
- Cache-Control immutable and TTL tuning — choosing TTLs for hashed and unhashed image URLs