---
title: 'Cache Busting'
source: 'https://academia.sh/en/courses/rendering-strategies/cache-busting'
course: 'Rendering Strategies and Infrastructure'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.
