Lesson 12 / 19
Cache Busting
Binding the file name to its content; hash generation and determinism, the chained change of the hash across dependent chunks, breaking the chain with an import map, and cache policy by name.
Contents
The files in the previous lesson’s output directory carried their source names. This
leaves an unresolved dilemma on the caching side. If the North Slope Measurement
Station site’s style file is served from the address /station.css and a long-lived
cache policy is applied to that address, the user keeps getting the old file once its
content changes. If the policy is kept short, revalidation happens on every visit and
the cache’s gain goes to revalidation round trips.
The dilemma’s source is that the address is independent of content. If the address is bound to the content, the dilemma disappears: the address does not change unless the content changes, and that address can be kept in the cache for close to forever; if the content changes, the address changes too, and since the new address was never in the cache, it downloads unavoidably. Cache busting is the naming method that establishes this bond.
Binding a Name to Content
The bond is established with a digest function. The file’s bytes are run through a cryptographic digest function, and a portion of the result is appended to the name. The result provides two properties at once.
Determinism: the same bytes give the same name. When the same source is built twice, the names come out identical; this is one of the conditions required for the build to be reproducible, and it will be used again in the preview deployments lesson.
No collisions: the odds of different bytes giving the same name are controlled by the digest length taken. Eight hexadecimal digits give close to four billion distinct values; the file count in a release stays small next to that, which makes it sufficient in practice. The truncation is a name-length decision, not a security decision — integrity checking is done with a separate mechanism, subresource integrity.
The hash must be computed over the final content. A hash computed before transforms does not represent the output’s bytes; the name stays the same in some cases where the content changes, and the cache keeps serving the old file.
Chained Change
This is the part of hashed naming that looks easy. The hard part is that outputs are bound to each other.
A chunk carries, in its body, the name of another chunk it loads dynamically. When that other chunk’s name changes, this chunk’s content changes; when its content changes, its hash changes; when its hash changes, its name changes. The change propagates through the graph from leaf to root. In a three-layer chain, a one-line change in a leaf module changes three files’ names and makes a returning visitor download all three again.
What breaks the chain is the name not being embedded in the body. If module specifiers are resolved through an import map, the chunk’s body does not carry its child’s hashed name; it carries only an unchanging specifier. The map is a small mapping written into the document and not cached.
Producing a Release
The script below runs two naming modes side by side and counts the bytes each of two change scenarios makes get re-downloaded. The style file and two assets are read from the previous lesson’s output directory.
This lesson’s script reads the output/ tree produced by the Asset Processing lesson;
that lesson’s blocks must run in this directory first.
// hash.mjs — content-hashed file names, chained changes, and an import map. import { createHash } from "node:crypto"; import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; const hash = (body) => createHash("sha256").update(body).digest("hex").slice(0, 8); const hashedName = (name, h) => name.replace(/\.([^.]+)$/, "-" + h + ".$1"); // The output set. Entries whose body is null are read from the fourth lesson's output directory. // The {{...}} placeholder is replaced with the child output's name in the release. const BODY = { "asset/station.svg": null, "asset/measurement.woff2": null, "station.css": '@font-face { font-family: "Measurement";\n' + ' src: url("{{asset/measurement.woff2}}") format("woff2"); font-display: swap; }\n' + '.station-map { background-image: url("{{asset/station.svg}}"); }\n', "chunk/chart.js": 'export const path = (m) => m.map((x) => x.temperature).join(" ");\n', "chunk/panel.js": 'import { path } from "{{chunk/chart.js}}";\n' + 'export const draw = (m) => "<svg>" + path(m) + "</svg>";\n', "entry.js": 'export const open = async () => (await import("{{chunk/panel.js}}")).draw;\n', }; const ORDER = ["asset/station.svg", "asset/measurement.woff2", "station.css", "chunk/chart.js", "chunk/panel.js", "entry.js"]; const SPECIFIER = { "chunk/chart.js": "north:chart", "chunk/panel.js": "north:panel" }; function publish(mode, changes = {}) { const name = {}, body = {}, size = {}; for (const filePath of ORDER) { let b = changes[filePath] ?? BODY[filePath] ?? readFileSync(join("output", filePath)); if (typeof b === "string") { // embedded mode: the child's hashed name is written into the body. // map mode: only module specifiers are left bare; the url() link in the style // file cannot be resolved by an import map, so its name keeps being embedded. b = Buffer.from(b.replace(/\{\{([^}]+)\}\}/g, (_, child) => mode === "map" && SPECIFIER[child] ? SPECIFIER[child] : "/" + name[child])); } name[filePath] = hashedName(filePath, hash(b)); body[filePath] = b; size[filePath] = b.length; } const importMap = { imports: Object.fromEntries( Object.entries(SPECIFIER).map(([filePath, specifier]) => [specifier, "/" + name[filePath]])) }; return { name, body, size, importMap: Buffer.from(JSON.stringify(importMap) + "\n") }; } const first = publish("embedded"); console.log("-- first release --"); for (const filePath of ORDER) console.log(" " + filePath.padEnd(25) + first.name[filePath]); console.log(" same source compiled twice, names identical: " + ORDER.every((p) => first.name[p] === publish("embedded").name[p])); rmSync("release", { recursive: true, force: true }); for (const filePath of ORDER) { const target = join("release", first.name[filePath]); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, first.body[filePath]); } const SCENARIO = { "leaf module changed": { "chunk/chart.js": BODY["chunk/chart.js"].replace('join(" ")', 'join(", ")'), }, "image asset changed": { "asset/station.svg": Buffer.from(readFileSync("output/asset/station.svg", "utf8") .replace('stroke-width="2"', 'stroke-width="3"')), }, }; for (const [scenarioTitle, changes] of Object.entries(SCENARIO)) { console.log("-- " + scenarioTitle + " --"); for (const mode of ["embedded", "map"]) { const before = publish(mode), after = publish(mode, changes); const changedNames = ORDER.filter((p) => before.name[p] !== after.name[p]); const mapChanged = !before.importMap.equals(after.importMap); const redownloaded = changedNames.reduce((t, p) => t + after.size[p], 0) + (mapChanged ? after.importMap.length : 0); console.log(" " + mode.padEnd(8) + " changed names: " + changedNames.join(", ")); console.log(" redownloaded: " + redownloaded + " B / total " + ORDER.reduce((t, p) => t + after.size[p], 0) + " B" + (mapChanged ? " (map also renewed)" : "")); } } console.log("-- produced in map mode --"); process.stdout.write(" entry.js : " + publish("map").body["entry.js"]); process.stdout.write(" panel.js : " + publish("map").body["chunk/panel.js"] .toString().split("\n")[0] + "\n"); process.stdout.write(" map : " + publish("map").importMap);
$ node hash.mjs
-- first release --
asset/station.svg asset/station-77c2bd82.svg
asset/measurement.woff2 asset/measurement-7b962f03.woff2
station.css station-f6353c3e.css
chunk/chart.js chunk/chart-f903b59b.js
chunk/panel.js chunk/panel-78d33707.js
entry.js entry-0aa70f11.js
same source compiled twice, names identical: true
-- leaf module changed --
embedded changed names: chunk/chart.js, chunk/panel.js, entry.js
redownloaded: 350 B / total 5711 B (map also renewed)
map changed names: chunk/chart.js
redownloaded: 163 B / total 5685 B (map also renewed)
-- image asset changed --
embedded changed names: asset/station.svg, station.css
redownloaded: 1361 B / total 5710 B
map changed names: asset/station.svg, station.css
redownloaded: 1361 B / total 5684 B
-- produced in map mode --
entry.js : export const open = async () => (await import("north:panel")).draw;
panel.js : import { path } from "north:chart";
map : {"imports":{"north:chart":"/chunk/chart-f903b59b.js","north:panel":"/chunk/panel-3ef97d1e.js"}}
What the Output Says
The verification line in the first section shows the naming is deterministic: names produced twice from the same source are identical. This becomes an identity for the release — whether two builds give the same result can be determined by comparing the name list, without opening the output.
The first scenario gives the chain’s cost. In embedded naming, the one-line change in the leaf chunk changes three files’ names and makes a returning visitor download 350 bytes. In import-map mode, only the changed file’s name changes, and the amount re-downloaded drops to 163 bytes — less than half. The ratio grows with the chain’s depth: in a four-layer chunk graph, embedded naming would change four files, map mode still just one.
The second scenario shows the method’s limit. A change to an image asset changes the
style file’s name in both modes, because the url() link inside a style file cannot be
resolved by an import map; the address has to be written there. Chain-breaking applies
only to module specifiers. This adds a new criterion to the fourth lesson’s embed
decision: embedding a frequently changing asset in the style file changes the style
file too, on every release.
Cache Policy by Name
Hashed naming alone produces no gain; the gain is produced by cache headers. The headers defined in the Caching Strategies lesson of the Browser and the Web Platform course are applied here by file class.
Hashed files are served with the longest lifetime and an immutability declaration: a one-year freshness window and the immutable flag. What guarantees this declaration is correct is the name itself — if the content changes, the file is no longer requested under that name. The immutable flag also removes revalidation requests made before the freshness window expires.
The document is never served long-lived. The document carries the hashed names; if the document is cached, the user keeps requesting the old names and never sees the new release at all. The correct policy for the document is to mark it to revalidate on every use and to give it a validator; if the content has not changed, the response comes back without a body.
The import map sits inside the document and is subject to the same policy as the document. If it is kept in a separate file, it must be marked the same way the document is; served long-lived, everything done to break the chain goes to waste.
Directly copied files keep their names, so they cannot get the hashed policy. These are given a medium-length freshness window and a validator.
Query-string versioning — appending a version parameter to the end of the address — appears to serve the same purpose but has two weaknesses: some intermediate caches do not cache addresses carrying a query string, and two copies of the same file can end up existing under two different parameters. Versioning through the name creates neither problem.
The Lifespan of Old Names
Hashed naming brings one obligation: old names must keep living for a while. If a new release is published while a user has the page open, the open page’s document keeps carrying the old names. A dynamic chunk request triggered from that page goes to the old name. If the new release deleted the old files, the request is not found, and the runtime error defined in the Code Splitting lesson results.
This is why a release is made additive instead of changing files in place: new names are written, old names are not deleted. Deletion is done as a separate cleanup task, with a delay that exceeds open sessions’ lifetime. The same rule is required for rollback as well, and it will turn into a release policy in the staged rollout lesson in the deployment topic.
Summary
- A content hash binds the file name to its content: the same bytes give the same name, changed bytes produce a new name; the hash must be computed over the final content.
- The naming is deterministic; two builds from the same source produce an identical name list, and that list can be used as the release’s identity.
- When a hashed name is embedded in the body, the change chains from leaf to root: in the example, a one-line change changes three files’ names and makes 350 bytes get re-downloaded.
- An import map pulls module specifiers out of the body and breaks the chain; the same change drops to 163 bytes. Asset links in a style file cannot be resolved this way.
- Policy is given by file class: hashed files are long-lived and immutable, the document and the import map revalidate on every use.
- Old names are not deleted right away; the chunks open pages will request stay live for a while longer.
Next Step
Everything produced up to this point is for release: chunks are merged, dead code is dropped, assets are processed, names are bound to content. All of these steps take time, and during development, none of them pay off. For someone who wants to change one line and see the result, re-bundling the entire graph, recomputing hashes, and reloading the page from scratch means an unnecessary wait and a lost screen state. The next lesson builds the compile’s second mode: a development server that serves the source as-is, re-evaluates only the module that changed, and propagates the change upward through the graph.
To keep your progress and take notes, Log in
My notes
Log in to take notes.