Cache-Busting Video and Audio Assets

Media breaks the assumptions every other fingerprinting technique rests on: the files are too large to run through a bundler, they are fetched in pieces rather than whole, and the thing that lists them — a streaming playlist — is the one file that must stay mutable.

The decision to make first is not which hash algorithm to use. It is whether the build system should ever open the file at all. For a 380 MB master, the answer is no, and everything else follows from that.

Keep Media Out of the Module Graph

Two paths for a large media file Routing a large video through the bundler forces the build to read and hash hundreds of megabytes and ship them in every deploy. Hashing at upload time keeps the file out of the module graph and stores only a URL in a manifest. Two paths for a large media file Through the bundler intro.mp4, 380 MB imported by a component Bundler reads it all heap spike, slow hashing Emitted with a hash 380 MB in every deploy Hashed at upload time intro.mp4, 380 MB never enters the graph Upload script hashes streams to object storage URL in a manifest build stays seconds long The bytes still get a fingerprint. The build just never has to read them.
Media is fingerprinted at the storage layer, not the build layer — the outcome is identical, the cost is not.
Concern Media through the bundler Media hashed at upload
Build memory Whole file buffered to hash Constant, streamed in 64 KB chunks
Build time Grows linearly with total media bytes Unchanged
Artifact size Every media file in every build artifact Zero
Re-upload on unchanged content Every deploy Skipped, the key already exists
Rollback Requires a full rebuild The old key is still there
Works for CMS uploads No Yes, same code path
Reference in code An import expression A manifest lookup or a stored URL

The one thing the bundler gives you that an upload script does not is the automatic rewrite of the reference. You have to replace that yourself, which in practice means a small JSON manifest the templates read — the same shape used everywhere else in asset manifest generation.

Hashing at Upload Time

Hash the file by streaming it, use the digest as the object key, and skip the upload entirely when that key already exists. Identical bytes then always land on the same URL, which makes re-running the script free and makes the URL immutable by construction.

// scripts/upload-media.mjs — run in CI before or after the app build
import { createHash } from 'node:crypto';
import { createReadStream, statSync } from 'node:fs';
import { readdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
  S3Client,
  PutObjectCommand,
  HeadObjectCommand,
} from '@aws-sdk/client-s3';

const SRC_DIR = 'media';
const BUCKET = process.env.MEDIA_BUCKET;
const PREFIX = 'media/';
// Change to 12 or 16 for a library of tens of thousands of objects.
const HASH_LEN = 8;

const CONTENT_TYPE = {
  '.mp4': 'video/mp4',
  '.webm': 'video/webm',
  '.m4a': 'audio/mp4',
  '.mp3': 'audio/mpeg',
  '.vtt': 'text/vtt',
};

const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-1' });

async function hashFile(file) {
  // Streaming keeps memory flat regardless of file size.
  const h = createHash('sha256');
  for await (const chunk of createReadStream(file, { highWaterMark: 65536 })) {
    h.update(chunk);
  }
  return h.digest('hex').slice(0, HASH_LEN);
}

async function exists(key) {
  try {
    await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
    return true;
  } catch (err) {
    if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
      return false;
    }
    throw err;
  }
}

const manifest = {};

for (const name of await readdir(SRC_DIR)) {
  const ext = path.extname(name).toLowerCase();
  const type = CONTENT_TYPE[ext];
  if (!type) continue;

  const file = path.join(SRC_DIR, name);
  const digest = await hashFile(file);
  const key = `${PREFIX}${path.basename(name, ext)}.${digest}${ext}`;
  manifest[name] = `/${key}`;

  if (await exists(key)) {
    console.log(`skip   ${key}`);
    continue;
  }

  await s3.send(
    new PutObjectCommand({
      Bucket: BUCKET,
      Key: key,
      Body: createReadStream(file),
      ContentLength: statSync(file).size,
      ContentType: type,
      // The key contains the digest, so the bytes can never change here.
      CacheControl: 'public, max-age=31536000, immutable',
    })
  );
  console.log(`upload ${key}`);
}

await writeFile('media-manifest.json', JSON.stringify(manifest, null, 2));

ContentLength is not optional when the body is a stream — without it the SDK buffers the whole file to determine the length, which reintroduces exactly the memory problem you were avoiding. And because the key encodes the digest, a re-encode produces a new key while the old object stays reachable, so a rollback needs nothing more than redeploying the previous manifest.

Range Requests and immutable

Players do not fetch a video file. They fetch byte ranges of it: a small probe for the container header, then progressively larger chunks as playback advances or the user seeks. Each of those is a separate HTTP request carrying a Range header, and each gets a 206 Partial Content response.

Range requests against an immutable media URL A player issues successive range requests for a hashed media URL; the edge answers each with a 206 partial response drawn from the same cached object, and no revalidation occurs because the URL is immutable. Range requests against an immutable URL Player Edge cache GET with Range: bytes=0-1023 206 with Content-Range and immutable GET with Range: bytes=1048576- 206 sliced from the same cached object no revalidation, the URL names the bytes Partial responses share the cache entry of the full object, keyed on the same URL.
Every seek is another range request; with a hashed URL none of them costs a revalidation round trip.

Three interactions matter.

A 206 is cached against the same key as the 200. Caches that support range requests store the full object (or the byte ranges they have seen) under the URL and slice responses out of it. Cache-Control: immutable applies normally: because the URL encodes the content digest, the cached bytes can never be wrong, so the player never issues an If-Range or If-None-Match revalidation on a seek. On a two-hour video that is the difference between zero and dozens of conditional requests per session.

Range support must actually be advertised. An origin that omits Accept-Ranges: bytes, or that returns 200 with the full body for a Range request, forces the player to download from byte zero on every seek. Compression middleware is the usual culprit — gzipping an MP4 achieves nothing and destroys range support, because the byte offsets no longer refer to the stored file.

Origin shield changes the economics. Without it, each edge node independently pulls ranges from origin and a popular video multiplies origin egress by the number of nodes. With it, the shield holds the object and the edges pull from it.

# Hashed media objects — immutable, ranged, never compressed.
location ~* ^/media/.+\.[0-9a-f]{8}\.(mp4|webm|m4a|mp3|ts|m4s)$ {
    gzip                off;
    brotli              off;
    add_header Accept-Ranges "bytes" always;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    # Do not let a Range request bypass proxy caching upstream.
    proxy_cache            media;
    proxy_cache_valid      200 206 365d;
    proxy_cache_key        "$uri";
    proxy_force_ranges     on;
    slice                  1m;
    proxy_set_header       Range $slice_range;
    proxy_pass             http://media_origin;
}

slice 1m makes Nginx fetch and cache the object in fixed 1 MB pieces rather than caching each arbitrary client range separately, which is what keeps the cache from filling with overlapping fragments. proxy_cache_valid 200 206 is required — omit the 206 and partial responses are never stored.

The Playlist Is the Mutable One

Adaptive streaming inverts the usual arrangement. HLS and DASH split a video into segments and list them in a manifest — .m3u8 or .mpd. The segments are write-once and safe to freeze. The playlist changes whenever the ladder changes, whenever a new rendition is added, and continuously during a live stream.

The playlist must not be immutable A streaming playlist keeps a fixed URL and a short TTL while every segment it names carries a content hash and a one-year immutable cache directive. The playlist must not be immutable master.m3u8 fixed URL, mutable max-age=10 Rewriting the playlist adds new segments; old ones keep their URL. seg-00001.b2f4a09c.ts immutable, one year seg-00002.7d1e3c85.ts immutable, one year seg-00003.c9a06b21.ts immutable, one year
Freeze the segments, revalidate the playlist — the inverse of the rule for HTML and its hashed scripts.

Marking a playlist immutable is the classic incident. A player that fetched the manifest before a re-package holds a segment list that no longer exists on origin; playback stops mid-stream with a run of 404s, and because the client cached the manifest for a year, nothing short of clearing its storage recovers it. For a live stream the same mistake freezes the sliding window and playback stalls at the last segment the client happened to receive.

# Segments: content-addressed, frozen.
location ~* ^/vod/.+\.[0-9a-f]{8}\.(ts|m4s|mp4|aac)$ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    add_header Accept-Ranges "bytes" always;
}

# Playlists: fixed URLs, must always be revalidated.
location ~* \.(m3u8|mpd)$ {
    # VOD: a short TTL is enough, the list changes only on re-package.
    add_header Cache-Control "public, max-age=10, must-revalidate" always;
    # Live: use no-cache and let the edge collapse concurrent requests.
    # add_header Cache-Control "no-cache" always;
    add_header Access-Control-Allow-Origin "*" always;
    types { application/vnd.apple.mpegurl m3u8; application/dash+xml mpd; }
}

Segment filenames come from the packager, so hashing them means configuring the packager’s naming template rather than a bundler. If the packager cannot emit content-derived names, the workable substitute is a per-encode directory: /vod/<content-hash-of-master>/seg-00001.ts. The directory component is the fingerprint, every segment beneath it is immutable, and a re-encode produces an entirely new tree — which also makes rollback a one-line manifest change, in the same spirit as rolling back cache keys.

Poster images

The poster attribute is the frame shown before playback starts, and it is a plain image on the ordinary image path — so it should carry a content hash like any other. The failure to watch for is a poster that outlives its video: the video is re-encoded and re-uploaded under a new key, the poster is left at its old hashed URL, and every card in the catalogue shows a thumbnail from a cut that no longer exists. Generate the poster from the same encode that produced the video, in the same script, and store both URLs in the same manifest entry so they can never diverge.

<video controls preload="metadata"
       poster="/media/intro-poster.5c81a24f.jpg"
       width="1280" height="720">
  <source src="/media/intro.9d4b70e1.webm" type="video/webm">
  <source src="/media/intro.3a6f28cc.mp4" type="video/mp4">
  <track kind="captions" srclang="en" label="English"
         src="/media/intro-en.71c9f0a3.vtt" default>
</video>

preload="metadata" is the right default for a page with several videos: it fetches only the container header of each, which is a single small range request per file rather than a multi-megabyte prefetch. Caption tracks are small text files but they are still assets — hash them too, or a corrected subtitle will not reach anyone who has already loaded the page.

Verification

# 1. Confirm the origin supports ranges and returns a real 206.
curl -sI -H 'Range: bytes=0-1023' https://cdn.example.com/media/intro.3a6f28cc.mp4 \
  | grep -Ei 'http/|accept-ranges|content-range|content-length|cache-control'
# Expect: HTTP/2 206, accept-ranges: bytes, content-range: bytes 0-1023/…,
#         cache-control: public, max-age=31536000, immutable

# 2. Confirm a second range does not trigger a revalidation.
curl -sI -H 'Range: bytes=1048576-1049599' \
  https://cdn.example.com/media/intro.3a6f28cc.mp4 | grep -Ei 'http/|x-cache|age'

# 3. Confirm the playlist is NOT immutable, and its segments are.
curl -sI https://cdn.example.com/vod/intro/master.m3u8 | grep -i cache-control
# Expect a short max-age with must-revalidate — never "immutable".

curl -s https://cdn.example.com/vod/intro/master.m3u8 \
  | grep -E '\.(ts|m4s)$' \
  | head -5 \
  | while read -r seg; do
      printf '%s %s\n' \
        "$(curl -sI "https://cdn.example.com/vod/intro/${seg}" \
             | grep -ci immutable)" "$seg"
    done
# Every line must start with 1.

# 4. Confirm the media object is not being compressed.
curl -sI -H 'Accept-Encoding: gzip, br' \
  https://cdn.example.com/media/intro.3a6f28cc.mp4 | grep -i content-encoding
# Expect no output at all.

# 5. Confirm the uploaded key matches the file you have locally.
LOCAL=$(sha256sum media/intro.mp4 | cut -c1-8)
echo "local=$LOCAL  deployed=$(jq -r '."intro.mp4"' media-manifest.json)"

Check 3 is the one to put in a smoke test. A playlist that acquires immutable through an over-broad CDN cache rule is invisible until a re-package, at which point it breaks playback for everyone who loaded the page in the previous year.

When to Reconsider

Put media through the bundler when the files are genuinely small — UI sound effects under 100 KB, a looping background clip of a few hundred kilobytes. The build cost is negligible and you get the reference rewriting for free.

Skip adaptive streaming for short clips. A 20-second product loop served as a single hashed MP4 with range support starts faster than an HLS ladder, needs no playlist, and has no mutable file to get wrong. Segmenting pays off past roughly two minutes, or when you need multiple bitrates.

Drop immutable on segments only when a packager rewrites segment files in place under stable names and you cannot change that. Then treat the whole tree as mutable with a moderate TTL and accept the revalidation traffic; the TTL choices for that case are covered in immutable and TTL tuning.