DeliveryGENERALNETWORK-SPECIFICSIMPLIFIED

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.

The question

How do I cache an asset for a year and still be able to change it tomorrow?

The user intent

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.

The obvious build

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.

Why it breaks

The version number is manual, so it is bumped when someone remembers. The one time it is forgotten is the one time it matters.

How it breaks in a real browser
  • 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=7 was 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".
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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.js other than the one whose hash is a3f19c, so Cache-Control: max-age=31536000, immutable is 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 answered 304.
  • 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.

Two ways to make the URL different
Query string, versioned by hand
<script src="/app.js?v=7"></script>
<link rel="stylesheet" href="/app.css?v=7">
Content hash, computed by the build
<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.

What the build emits, and what the document says
1dist/
2 index.html <- no-cache: the only mutable thing
3 assets/
4 app.a3f19c.js <- max-age=31536000, immutable
5 app.7b20e4.css <- max-age=31536000, immutable
6 vendor.0e91d2.js <- unchanged since March; never re-downloaded
7 settings.4c8ab1.js <- lazily loaded chunk
8 inter.d17f03.woff2
9 manifest.json <- name -> hashed name, generated
10
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).

One change, four new names
contents changedthe imported name is part of my bytessamewith indirection, the cascade stops hereOne line changes in format-date.tsshared.b41c07.js → shared.f92a55.jsorders chunk: imports shared by namereports chunk: imports shared by nameManifest indirection: only shared + manifest changeentry: imports both route chunksindex.html now names a new entryReturning user re-downloads all four
UserLLMAgentToolDataDecisionHumanGuardrail

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.

Hashed-asset failures and what they actually are
TriggerSymptomCauseResponse
Old assets deleted on deployA button in an open tab does nothing; console shows a failed dynamic importThe document names build N; only build N+1 existsRetain 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 everythingReturning users download the whole application on every deployImporter chunks embed the hashed names of their importsEnable the bundler's manifest or module-id indirection, then verify by diffing manifests across a one-line change (Bundle Analysis).
Non-deterministic buildHashes move with no source changeTimestamps, absolute paths, or unordered iteration reaching the outputMake the build reproducible first; nothing here works until identical input produces identical output.
Hashed name hardcoded in a template or hintA 404 on every load after a deployA generated name written by handGenerate every reference from the manifest, including preload hints (Resource Hints).
Document cached longer than intendedUsers pinned to a build whose assets you have stopped servingAn intermediary or a CDN rule overriding no-cache on the documentVerify document headers on a real deploy from more than one region (CDN Delivery).
Service worker precache from a different buildThe application runs a mixture of two versionsThe precache manifest and the document were published at different momentsVersion 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 rejectionError reporting fills with noise; users see nothingNo handler around dynamic importsCatch 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

Failure modes
  • 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).
What can arrive out of order
  • 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).
Security
  • 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 immutable entry 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).
Misreads
  • "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.
  • "immutable is just a longer max-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

How you would see this
  • 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).
Slow device, slow network, large data, old tab
  • 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).
What this costs
  • 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.

Domains that do not exist yet
  • 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.
OS & Networkinghttp-versionshttp2