Setting Cache-Control Headers for Uploaded Media
Put a version or content hash in every media URL and serve those objects with Cache-Control: public, max-age=31536000, immutable; serve anything whose content changes under a stable URL — HLS and DASH manifests, “current avatar” aliases — with a short max-age plus stale-while-revalidate; serve per-user private media with private or no-store; and set these headers as object metadata at write time so every CDN and browser sees them without per-request logic.
Caching is where media delivery either becomes cheap or becomes a support queue. Too short, and every view reaches the origin, costing egress and adding latency; too long on a mutable URL, and users see their old avatar for a week after changing it; public on a private file, and a shared cache serves one user’s document to another. This page belongs to secure media delivery in media processing and delivery pipelines. The versioned layout it depends on is the one used in adaptive bitrate video streaming and responsive image delivery.
When to use this approach
- You serve uploaded media through a CDN and want hit rates above 90% without ever showing stale content.
- Users change their media — replace an avatar, re-upload a video, edit a document — and expect to see the change immediately.
- Some media is private per user and must never be stored in a shared cache.
Prerequisites
- A URL scheme with a version, content hash or upload ID in the path for every derived file (
/media/<assetId>/v3/720p/seg_004.m4s). - Write access to object metadata at upload or processing time (
CacheControlon S3PutObject,cacheControlon GCS,x-ms-blob-cache-controlon Azure). - A CDN that honours origin
Cache-Control(all major ones do by default) or a cache policy you can configure to respect it. - A plan for the stable URLs you do need, such as a profile’s current avatar.
One rule per kind of object
immutable covers the bulk of traffic.Implementation
Set the header when the object is written, from one function that encodes the policy:
import { S3Client, PutObjectCommand, CopyObjectCommand } from "@aws-sdk/client-s3";
import { readFile } from "node:fs/promises";
const s3 = new S3Client({});
export type MediaKind = "variant" | "segment" | "init" | "manifest" | "alias" | "private" | "original";
export function cacheControlFor(kind: MediaKind): string {
switch (kind) {
case "variant":
case "segment":
case "init":
return "public, max-age=31536000, immutable";
case "manifest":
// Browsers re-check every 5 min; the CDN keeps it an hour (purge on re-publish).
return "public, max-age=300, s-maxage=3600";
case "alias":
return "public, max-age=60, stale-while-revalidate=600";
case "private":
return "private, max-age=300";
case "original":
return "private, no-store";
}
}
const TYPES: Record<string, string> = {
".webp": "image/webp", ".avif": "image/avif", ".jpg": "image/jpeg",
".m4s": "video/iso.segment", ".mp4": "video/mp4", ".m3u8": "application/vnd.apple.mpegurl",
};
export async function putMedia(bucket: string, key: string, file: string, kind: MediaKind): Promise<void> {
const ext = key.slice(key.lastIndexOf("."));
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: await readFile(file),
ContentType: TYPES[ext] ?? "application/octet-stream",
CacheControl: cacheControlFor(kind),
}));
}
/** Fix headers on objects written before this policy existed: copy-in-place with new metadata. */
export async function rewriteCacheControl(bucket: string, key: string, kind: MediaKind, contentType: string): Promise<void> {
await s3.send(new CopyObjectCommand({
Bucket: bucket,
Key: key,
CopySource: `${bucket}/${encodeURIComponent(key)}`,
MetadataDirective: "REPLACE", // required, or S3 keeps the old metadata
ContentType: contentType, // REPLACE drops everything not restated
CacheControl: cacheControlFor(kind),
}));
}
// Usage
await putMedia("media", "media/9c1f/v3/720p/seg_004.m4s", "/tmp/out/720p/seg_004.m4s", "segment");
await putMedia("media", "media/9c1f/v3/master.m3u8", "/tmp/out/master.m3u8", "manifest");
console.log(cacheControlFor("alias"));
// public, max-age=60, stale-while-revalidate=600
Line-by-line on the directives that matter
immutabletells browsers not to revalidate the object even on reload. Without it, a user pressing refresh on a gallery sends dozens of conditional requests that all return 304 — correct but slow. It is only safe because the URL contains a version.max-age=31536000is one year, the conventional maximum. Longer values are clamped by caches anyway.s-maxageapplies only to shared caches (the CDN), overridingmax-agethere. Manifests can then stay at the edge for an hour — cheap — while browsers re-check every five minutes.stale-while-revalidate=600lets a cache serve a slightly stale alias instantly while fetching the new version in the background. A user changing their avatar sees it within a minute; everyone else never waits on the origin.privateforbids shared caches from storing the response while still letting the user’s own browser cache it. Use it for anything authorised per user, and make sure the CDN respects it.MetadataDirective: "REPLACE"on the copy. S3 does not let you edit metadata in place; you copy the object onto itself with new metadata, andREPLACEdrops every header you do not restate — includingContent-Type, which is why the function takes it.
How the versioned layout makes updates instant
Configuration gotchas
CloudFront ignores your max-age. The distribution’s cache policy has a minimum TTL above zero or a default that overrides origin headers. Use a policy with “minimum TTL 0, maximum TTL 31536000” and let origin Cache-Control decide, or CachingOptimized which does this.
Private files end up in the CDN cache. The origin sent no Cache-Control and the CDN applied a default TTL, or a cache policy forced caching. Always send an explicit private or no-store for per-user media, and put private media on a behaviour whose cache key includes the signature or authorisation.
Cache-Control set, but browsers keep revalidating. You added no-cache somewhere (a framework default on all responses, or an Expires: 0). no-cache means “revalidate every time”, which defeats immutable. Check the actual response headers, not your config.
Re-uploads show the old file for hours. The URL does not change when the file does — a user’s avatar stored at /avatars/42.jpg with a one-day TTL. Either version the URL, or shorten the TTL and add stale-while-revalidate; purging CDN caches on every edit does not reach browser caches.
Designing URLs so caching can be aggressive
Headers can only be as aggressive as the URL scheme allows, so the real decision happens when you choose object keys. Three schemes are common, and only two of them cache well.
Content-addressed keys put a hash of the bytes in the path: /img/c3d4e5f6/256.webp. Identical content always has the identical URL, so a re-upload of the same photo reuses every cached copy, and a changed photo is by definition a new URL. The cost is a lookup: pages must read the current hash from the database to render an image.
Versioned keys put a monotonically increasing number or an upload ID in the path: /media/9c1f/v3/master.m3u8. They are easier to reason about than hashes for multi-file packages — every file in a package shares the version — and they make “publish a new version, keep the old one serving” a natural operation. This is the scheme used throughout the video and image topics on this site.
Stable mutable keys — /avatars/42.jpg overwritten on every change — look simplest and cache worst. Any TTL long enough to help hit rates is long enough to show stale content, and every fix involves purges that never reach browser caches. Use them only as short-lived aliases pointing at one of the other two schemes.
Whichever immutable scheme you choose, keep the rule absolute: nothing ever writes to an existing versioned or content-addressed key. Enforce it in code by refusing PutObject on keys that already exist in those prefixes (S3 now supports conditional writes with If-None-Match: *), so a buggy re-processing job cannot silently change bytes behind a year-long cache header. When a job needs to change output, it writes a new version and moves the pointer.
Hit rate by policy
Verification
# Versioned segment: immutable, one year.
curl -sI https://media.example.com/media/9c1f/v3/720p/seg_004.m4s | grep -i cache-control
# cache-control: public, max-age=31536000, immutable
# Manifest: short browser TTL, longer edge TTL.
curl -sI https://media.example.com/media/9c1f/v3/master.m3u8 | grep -iE 'cache-control|age:|x-cache'
# Private file never cached at the edge: the CDN reports a miss or bypass every time.
for i in 1 2; do curl -sI -b "session=$S" https://media.example.com/private/9c1f/report.pdf | grep -i x-cache; done
# x-cache: Miss from cloudfront
# x-cache: Miss from cloudfront
Frequently Asked Questions
Should I use ETags as well?
Yes — storage services set them automatically, and they make revalidation of short-TTL objects (manifests, aliases) cheap: a 304 with no body. For immutable objects they rarely come into play because browsers do not revalidate those at all.
How do I invalidate a versioned file if it was wrong?
You do not invalidate; you publish a new version and point the asset at it. If the wrong file must disappear (a privacy request), delete the object and purge that exact URL from the CDN; browser caches cannot be purged, which is another reason sensitive media must be private from the start.
What about Expires?
Cache-Control: max-age overrides Expires in every current client. Omit Expires unless you must support HTTP/1.0 proxies, and never send both with conflicting values.