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
| 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.
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.
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.
Related
- Hashing images, fonts, and media — parent guide on which assets the bundler can hash
- Web fonts and
@font-facerewriting — the other asset class the bundler must rewrite - Responsive image srcsets — poster and thumbnail variant sets
- Cache key architecture — how range requests and query strings enter the cache key
- Cache-Control immutable and TTL tuning — picking TTLs for playlists versus segments