Content-Hashed Assets
app.a3f19c.js is a caching strategy, not a naming convention: you never invalidate a URL, you stop referencing it — with consequences for code splitting and for tabs that have been open all week.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How do I cache an asset for a year and still be able to change it tomorrow?
A returning user should download nothing they already have, and should never, under any circumstances, run half of last week's application against half of this week's.
Cache the assets for a long time and add a query string when they change: app.js?v=7. The URL is different, so the cache is bypassed.
The version number is manual, so it is bumped when someone remembers. The one time it is forgotten is the one time it matters.
- The version number is manual, so it is bumped when someone remembers. The one time it is forgotten is the one time it matters.
- One version number covers every file, so a one-line CSS change invalidates the entire JavaScript bundle for every user in the world.
- Some intermediary caches historically ignored query strings when keying, so
?v=7was not reliably a different key everywhere (CDN Delivery). - It says nothing about *what changed*, so nothing downstream can reason about it: not the CDN, not the browser, not a service worker, not a colleague reading a diff.
- And it does not solve the real problem at all, which is not "how do I bust the cache" but "how do I guarantee that a document and the assets it names are from the same build".
What is actually happening
In the browser, not in the framework.
- The build hashes each output file's contents and puts the hash in the filename:
app.a3f19c.js. Identical bytes produce an identical name; different bytes produce a different one. - That makes the URL content-addressed, and a content-addressed URL can never be stale. There is no version of
app.a3f19c.jsother than the one whose hash isa3f19c, soCache-Control: max-age=31536000, immutableis not optimistic — it is a statement of fact (Browser HTTP Caching). - Invalidation stops existing as an operation. You never tell a cache to forget something; you stop referencing it, and the old entry sits unread until it is evicted.
- The document becomes the only mutable thing in the deploy. It names the hashed assets, so it must not be cached long — usually
no-cache, so every navigation revalidates in one round trip that is almost always answered304. - With code splitting the graph matters: each chunk's hash covers its own contents, but a chunk also contains the *names* of the chunks it imports. So changing a shared chunk changes its name, which changes the contents of every chunk that imports it, which changes their names — a cascade (Code Splitting).
- Build tools mitigate the cascade by indirection: the import map or manifest that resolves chunk names lives in one place, so importers reference a stable identifier and only the manifest changes. This is why "why did my whole bundle re-hash" is a build-configuration question (The Module Graph).
- And the old files must stay reachable. A tab opened before the deploy holds a document naming assets from the previous build, and will request them the moment the user interacts with something lazily loaded (Long-Lived Clients and Version Skew).
What this makes the browser do
And which of it is avoidable.
- For a returning user with an unchanged asset: nothing at all. No request, no revalidation, no connection. This is the cheapest outcome available in the entire domain.
- For a changed asset: an ordinary download of a URL never seen before, at whatever priority the document assigned it.
- Holding many hashed entries in the cache over time. Old ones are evicted normally; they are not cleaned up by a deploy, because the browser has no idea a deploy happened.
- For a lazily loaded chunk, a request at interaction time — which is where a missing old asset turns into a visible failure rather than a silent one (Lazy Loading).
The name is the version
The whole idea fits in one sentence: put the content's hash in its filename, and a URL can never refer to two different things. Everything else in this lesson is a consequence of that sentence.
The comparison below is worth sitting with, because the query-string version *looks* equivalent. Both produce a new URL. The difference is who is responsible for it being correct — a person remembering to bump a number, or the build computing it from the bytes it just wrote.
<script src="/app.js?v=7"></script> <link rel="stylesheet" href="/app.css?v=7">
<script type="module" src="/assets/app.a3f19c.js"></script> <link rel="stylesheet" href="/assets/app.7b20e4.css">
The query-string version has one version number for every file, so any change re-downloads everything; it depends on a human remembering to bump it; and the underlying URL still serves different bytes over time, so any cache that keys on path alone can serve the wrong thing. The hashed version derives the identity from the content, so an unchanged file keeps its name and stays cached, a changed file gets a name no cache has ever seen, and the guarantee holds without anyone maintaining it.
1dist/2 index.html <- no-cache: the only mutable thing3 assets/4 app.a3f19c.js <- max-age=31536000, immutable5 app.7b20e4.css <- max-age=31536000, immutable6 vendor.0e91d2.js <- unchanged since March; never re-downloaded7 settings.4c8ab1.js <- lazily loaded chunk8 inter.d17f03.woff29 manifest.json <- name -> hashed name, generated10 11<!-- index.html, generated from the manifest, never hand-written -->12<link rel="stylesheet" href="/assets/app.7b20e4.css">13<link rel="preload" href="/assets/inter.d17f03.woff2"14 as="font" type="font/woff2" crossorigin>15<script type="module" src="/assets/app.a3f19c.js"></script>Two lifetimes, two responsibilities. index.html revalidates on every navigation — one round trip, almost always a 304 — and everything it names is cached until the heat death of the browser profile. Change any asset and its name changes, so the document changes, so the revalidation returns a new document that names the new file. The cache is never wrong because it is never asked to be.
The cascade nobody expects
Content hashing has one genuinely surprising interaction, and it is with code splitting. A chunk's hash covers its contents — and its contents include the *names* of the chunks it imports. So when a shared module changes, its own name changes, which changes the bytes of everyone who imports it, which changes their names too.
Follow the diagram. One line changes in a date formatter. That re-hashes the shared chunk, and the two route chunks that import it, and the entry that imports them. Four files with new names; a returning user who already had three of them downloads all four. The application is correct and the caching benefit is gone.
The fix is indirection, and every serious bundler implements some version of it: importers reference a stable module identifier, and one small generated manifest maps identifiers to hashed names. Only the manifest and the genuinely changed chunk get new names. If your builds show a full cascade on a one-line change, that is a configuration finding, not a law of nature (Bundlers Compared).
The tab that has been open since Tuesday
Content hashing makes assets immutable, which makes a new problem visible rather than creating one: a client that loaded a document from build N will ask for build N's assets, at whatever moment the user happens to click something. If build N's assets were deleted when build N+1 shipped, that click does nothing (Long-Lived Clients and Version Skew).
This is the failure with the worst diagnostics in the module. The console shows a rejected dynamic import. The user sees a button that does not respond. There is no error page, no status code the user can report, and it reproduces for exactly nobody on the team, because everyone on the team reloaded this morning.
The answers are ordinary and boring: keep several builds' assets reachable, catch chunk-load failures and offer a reload, and deploy in an order where the document is always the last thing to change. None of them is clever. All of them are the difference between a caching strategy that works and one that works until Thursday.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Old assets deleted on deploy | A button in an open tab does nothing; console shows a failed dynamic import | The document names build N; only build N+1 exists | Retain the last several builds. Storage is cheap and this is not a bug an open tab can be blamed for (Deploying a Frontend). |
| One-line change re-hashes everything | Returning users download the whole application on every deploy | Importer chunks embed the hashed names of their imports | Enable the bundler's manifest or module-id indirection, then verify by diffing manifests across a one-line change (Bundle Analysis). |
| Non-deterministic build | Hashes move with no source change | Timestamps, absolute paths, or unordered iteration reaching the output | Make the build reproducible first; nothing here works until identical input produces identical output. |
| Hashed name hardcoded in a template or hint | A 404 on every load after a deploy | A generated name written by hand | Generate every reference from the manifest, including preload hints (Resource Hints). |
| Document cached longer than intended | Users pinned to a build whose assets you have stopped serving | An intermediary or a CDN rule overriding no-cache on the document | Verify document headers on a real deploy from more than one region (CDN Delivery). |
| Service worker precache from a different build | The application runs a mixture of two versions | The precache manifest and the document were published at different moments | Version the service worker with the build, and let it take over only after the client is ready (The Service Worker Lifecycle). |
| Chunk-load failure surfaces as an unhandled rejection | Error reporting fills with noise; users see nothing | No handler around dynamic imports | Catch it, classify it as a version-skew failure, and offer an explicit, focusable reload (Frontend Error Tracking). |
How to build it
Most important first.
- Hash every asset the document does not name by hand: scripts, stylesheets, fonts, and images referenced from code. Give them a long lifetime and
immutable. - Do not hash the document. It is the pointer, and a pointer that is cached long is a pointer you cannot update.
- Generate every reference from the build manifest — script tags, preload hints, service worker precache lists. A hand-written reference to a hashed name is a guaranteed future incident (Resource Hints).
- Deploy in the safe order: upload the new hashed assets first, publish the new document last. The new names collide with nothing, so there is no window in which a document points at bytes that are not there yet.
- Keep the previous few builds' assets reachable rather than deleting them on deploy. Storage is cheap; a user with a week-old tab is not a bug to be fixed by a 404 (Long-Lived Clients and Version Skew).
- Handle the chunk-load failure explicitly. A dynamic import that rejects because its chunk no longer exists should offer a reload, not surface as an unhandled rejection (Frontend Error Tracking).
- Understand your bundler's hashing mode before optimising it. Hashing the pre-transform source, the post-transform output, or the output including dependency names produce very different cascade behaviour (Bundlers Compared).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- The success case is an accessibility improvement: a repeat visit with nothing to download reaches usable, navigable content immediately, removing the interval in which assistive technology has nothing to work with.
- The failure case is severe. A chunk that fails to load leaves a control present, focusable and inert — a keyboard user can reach it, activate it, and receive nothing. Silent failure is worse than a visible error for anyone not watching a spinner (Keyboard Operability).
- If a stale-version failure prompts a reload, the prompt must be a real, focusable, announced element — not a toast that disappears before a screen reader reaches it (Live Regions and Announcement).
- When the application recovers by reloading, restore focus and scroll position deliberately. Being returned to the top of a page mid-task is a much larger cost for someone navigating by keyboard or magnifier (Focus Management).
What can go wrong
- Deleting old assets on deploy. Every open tab and every user mid-session loses access to code it is about to request, and the failure appears as a dead button.
- The hash cascade: a one-character change in a shared utility re-hashes every chunk that imports it, and returning users download the entire application again. Correctness is fine; the caching benefit evaporated.
- Non-deterministic builds. If the same source produces different bytes on different machines, the hash changes for no reason, and there is no cache reuse across builds at all.
- A hashed name written by hand into a template or a hint, which then points at a file that no longer exists.
- The document cached long by an intermediary despite
no-cache, pinning users to a build whose assets you have since stopped serving (CDN Delivery). - A service worker precaching one build's manifest while the document is from another, so the two layers disagree about which version of the application is running (The Service Worker Lifecycle).
- A deploy lands between a document being served and its chunks being requested. Uploading assets before publishing the document makes this harmless; the reverse order makes it a blank page.
- A user with an open tab and a user opening a new tab run different builds against the same API at the same time, which is version skew and must be designed for rather than prevented (How API Shape Drives UI Complexity).
- A service worker activating mid-session can start serving a different build's assets to a document from the previous one, which is the same race with a third participant (The Service Worker Lifecycle).
- A content hash is an integrity property you get for free within your own pipeline: if the bytes at a hashed URL do not match the hash, something replaced them.
- This composes with subresource integrity, which lets the document assert the expected hash so the browser refuses a substituted file outright (Artifact and Build Integrity in Security Engineering).
- A long-lived
immutableentry is only safe because the URL is content-addressed. Apply the same headers to a mutable URL and you have created a file you cannot revoke. - Keeping old builds reachable keeps old vulnerable code reachable. That is a real tension: the availability argument says keep them, the security argument says expire them, and the resolution is a bounded retention window rather than a rule (Software Supply Chain Security in Security Engineering).
- Source maps are usually hashed too. Deciding whether they are publicly reachable is a separate decision from whether the assets are (Source Maps).
- "Content hashing is cache busting." Busting is what you do to a URL you keep reusing. Hashing means you never reuse it, which is why there is nothing to bust.
- "
immutableis just a longermax-age." It also stops revalidation on reload, which is the case where users most often pay for a round trip they did not need. - "The hash guarantees users get the new version." It guarantees they do not get the wrong bytes for a name. Whether they get the new *document* is a separate question, and the document is where over-caching bites (Browser HTTP Caching).
- "Old assets can be deleted after a deploy." Only if no client is holding a reference, and clients hold references for as long as tabs stay open.
- "Everything should be hashed." Not the document, and not anything a third party links to directly — a stable URL is a feature for those.
Measuring it, and what changes in the field
- Deploy twice with no source change and compare the emitted filenames. If any hash moved, the build is not deterministic and every other measurement is noise.
- Change one line in one leaf module and diff the manifest. The number of files whose names changed is your cascade factor, and it is the single most useful number in this lesson (Bundle Analysis).
- For a returning user, count requests. The target is zero for unchanged assets, and the Network panel's cache column shows whether you got it (Debugging the Network).
- Track chunk-load failures in error reporting as their own category. A rise after a deploy is the old-tab problem, measured (Frontend Error Tracking).
- On a slow connection, a returning visit that downloads nothing is the largest single improvement available, and it is entirely invisible on a first visit.
- On a long-lived tab — a dashboard, an internal tool, anything people leave open — the old-asset problem is not an edge case, it is the normal case (Long-Lived Clients and Version Skew).
- On a frequently deployed application, the cascade factor is what decides whether returning users get any benefit at all: ten deploys a day with a full cascade is the same as no caching.
- Behind a service worker, the precache manifest is another copy of the same build's names, and a mismatch between it and the document is a whole additional version-skew surface (Caching Strategies).
- Every reference must be generated, which couples templates, hints and service worker manifests to build output and makes the build a harder thing to reason about.
- Keeping old builds reachable costs storage and keeps old code — including old vulnerable code — available.
- Optimising the cascade means stable chunk boundaries, which constrains how you split code and can force awkward module structure to protect a caching property.
- Content hashing solves version skew for assets and does nothing for the API those assets talk to. That skew is a separate problem with a separate answer (How API Shape Drives UI Complexity).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALContent addressing is a property of URLs and caches, not of any particular tool, so the strategy is identical whatever builds your assets. What differs is what each bundler hashes — source, output, or output including dependency names — which changes cascade behaviour substantially between tools and between major versions of the same tool.
- NETWORK-SPECIFICThe benefit is proportional to what a returning user avoids, so it is largest on high-latency connections where each avoided request is a round trip. It also interacts with splitting: on HTTP/1.1, many small hashed chunks competed for a tiny connection pool, while on HTTP/2 and HTTP/3 they multiplex, which is what made fine-grained hashed chunks practical at all (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).
- SIMPLIFIEDReal build tools add several layers this lesson flattens: separate manifests, module preload graphs, deterministic module ids, and hashing modes that deliberately exclude dependency names to break the cascade. The model here predicts the caching behaviour correctly and understates how much configuration sits behind it.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — an open tab running an old build is a stale replica of your application, and "old client, new server" is the version-skew problem that domain treats as a first-class design constraint rather than a deploy accident.
- — Software Design — content addressing is the same idea as an immutable value with structural identity: the name is derived from the contents, so equality is decidable and mutation is impossible by construction.