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

  1. 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).
  2. Write access to object metadata at upload or processing time (CacheControl on S3 PutObject, cacheControl on GCS, x-ms-blob-cache-control on Azure).
  3. A CDN that honours origin Cache-Control (all major ones do by default) or a cache policy you can configure to respect it.
  4. A plan for the stable URLs you do need, such as a profile’s current avatar.

One rule per kind of object

Cache-Control by media object type Versioned variants, segments and init files are immutable for a year. Manifests and stable alias URLs get a short max-age with stale-while-revalidate. Private per-user files are private with a short max-age or no-store. Originals are no-store at the public edge. Does the URL's content ever change? object Cache-Control changes? image variants, segments, init.mp4 public, max-age=31536000, immutable never VOD manifests (.m3u8, .mpd) public, max-age=300, s-maxage=3600 rarely stable alias (/u/42/avatar) public, max-age=60, stale-while-revalidate=600 on edit private user files private, max-age=300 per user originals, signed downloads private, no-store sensitive If a URL can change, make it change: put the version in the path and let the old URL stay cached forever.
Most media objects never change once written, which is why a versioned URL plus 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

  • immutable tells 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=31536000 is one year, the conventional maximum. Longer values are clamped by caches anyway.
  • s-maxage applies only to shared caches (the CDN), overriding max-age there. Manifests can then stay at the edge for an hour — cheap — while browsers re-check every five minutes.
  • stale-while-revalidate=600 lets 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.
  • private forbids 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, and REPLACE drops every header you do not restate — including Content-Type, which is why the function takes it.

How the versioned layout makes updates instant

Updating an avatar with a stable alias and versioned files The profile page references a stable alias URL cached for 60 seconds. The alias redirects to a versioned file cached for a year. When the user uploads a new avatar, a new versioned file is written and the alias is updated; within 60 seconds caches fetch the new alias and follow it to the new file, while the old file stays cached harmlessly. Short-lived pointer, long-lived files profile page <img src=alias> /u/42/avatar 302, max-age=60 /img/a1b2…/256.webp old — still cached, unused /img/c3d4…/256.webp new — immutable, 1 year Or skip the redirect: render the versioned URL directly from the database and only the page needs refreshing.
Only the tiny pointer is ever stale, and only for a minute; the bytes behind it are cached forever because they never change.

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

CDN hit rate for image traffic under three header policies With no Cache-Control and a CDN default of one day, image hit rate is about 71 percent. With max-age one hour on mutable URLs it is about 64 percent. With versioned URLs and immutable one-year caching it is about 97 percent. Edge hit rate, same image traffic no header, CDN default 1 d 71% mutable URLs, 1 h 64% versioned + immutable 97% Versioning is what allows a long TTL; the long TTL is what buys the hit rate. Neither works alone.
Every percentage point of hit rate is origin egress you do not pay for and latency your users do not see.

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.