Vivian Voss

The Cost That Changes Owner

caching import maps javascript web performance

Lean Software

Argue that a website does not need a compile step and the reply arrives within the hour, usually polite and always the same shape. An engineer put it to me recently in a single clause that is hard to improve on: if you own the build step for the browser, you also own the cache invalidation. He added the detail that makes it bite. The browser may well work out that the raw file changed. An optimised import is often left exactly as it was.

It is the best objection in the set, which is why it deserves a full accounting rather than a shrug. The answer is not that the problem goes away. It does not. The answer is that the bill lands on a different desk, and that it is smaller when it gets there.

The Job the Bundler Does Well

Grant the mechanism its due first, because it is genuinely good engineering.

HTTP caching is unforgiving. A stale asset served from a browser cache is a production fault that nobody can recall, and the traditional remedies were all a bit of a palaver: query strings that proxies handled inconsistently, and version numbers that somebody eventually forgot to raise. Fingerprinting solved it properly. Put a hash of the file's contents into its name, and any cache anywhere treats a changed file as a different file, with no ambiguity to negotiate.

That unlocks the pairing every serious deployment uses now: HTML served with no-cache so it is always revalidated, and every fingerprinted asset served with max-age=31536000, immutable, which tells the browser not to so much as ask for a year. Webpack, Vite and Rollup do all of this without being asked. If the question is whether the build step earned its keep here, the honest answer is that it did.

The Part of the Bill Nobody Reads Out

Now the line item underneath.

When a bundler fingerprints a file, the new name has to appear somewhere, and where it appears is inside every file that imports it. Change one small module and its hash changes, which changes the text of each importing chunk, which changes their hashes, which changes the text of anything importing those. Philip Walton, who documented this in detail, puts the mechanism plainly: the hashes appear in the contents of other files, which causes those files to be invalidated when the hashes change.

One edit, three invalidations EDITED utils.7f3a91.js → utils.b204ce.js name NOT EDITED chunk.a91f04.js → chunk.6dd28b.js name NOT EDITED main.0c17ea.js → main.f38b55.js The hash of each file is written into the file that imports it, so a changed name changes the contents of its parent. WHAT THE RETURNING VISITOR DOWNLOADS Everything above the edit, because one utility gained a line. The edited file is the smallest part of the bill.

The practical shape of that is a returning visitor downloading a substantial part of your application because one utility function gained a line. Not the changed file. The chain above it, on their mobile data, for a change they will never see.

And the failure runs in the other direction too, which is the observation that opened this piece. The browser is perfectly capable of noticing that a raw file has changed. What it cannot notice is that an optimised import still points where it always pointed. Correctness here has never depended on the browser being clever. It depends on somebody keeping the names honest.

Walton's recommended remedy is worth quoting for its provenance as much as its content, since it comes from the person describing the problem rather than from anyone selling the alternative: an indirection layer, and of the available ones he calls import maps the simplest and easiest to implement.

One Place Instead of Every Place

An import map is a small block of JSON sitting in the page, and all it does is answer one question: when this code asks for a module by name, which file do you actually fetch? Source files go on importing something called utils. The map, and only the map, knows that utils currently means utils.7f3a91.js. Change the file, change that one line, and every importer is redirected without having been touched.

<script type="importmap">
{
  "imports": {
    "utils": "/js/utils.7f3a91.js",
    "cart":  "/js/cart.a91f04.js"
  }
}
</script>
One edit, one line EDITED utils.7f3a91.js → utils.b204ce.js name ONE LINE REWRITTEN the import map "utils": …b204ce.js name UNCHANGED chunk.a91f04.js main.0c17ea.js Importers ask for the plain name, so their contents never change, their hashes hold, and their cached copies survive the deploy. WHAT THE RETURNING VISITOR DOWNLOADS The edited file, and the page carrying the map. The cost now scales with what changed, not with what did not.

Move the hashes there and the cascade has nowhere to travel. One module changes, its entry in the map changes, and every other file on the machine keeps the name it had and therefore keeps its place in the cache. The team at Banno, who run this in production, report exactly that: the unchanged parents retain their hashes and their cached copies while the map points them at the new child. Since last year the map can carry integrity metadata as well, so the same block that resolves the name also states the expected hash for subresource integrity.

Notice what the work has become. Something still has to compute hashes and write them into a map. That something reads your files and produces a catalogue entry. It does not rewrite your sources, and what it emits is one small document rather than a new edition of everything.

Where Bookkeeping Turns Back into Building

The distinction holds precisely as long as that stays true, and it is worth being strict about the boundary, because it is easy to cross without noticing.

A process that hashes files and updates a map is bookkeeping. A process that rewrites your source into something else and ships that instead is a build, whatever you call the script. If your map generation starts transpiling, or quietly bundles the vendor chunk on the way past, then the build step has not gone anywhere. It has changed its name and kept its habits.

And the limits deserve stating, since the alternative has them. Browsers discover imports by executing the module graph rather than by scanning the HTML, so a preload scanner will not find them for you and the first visit needs help through preloading or a service worker. Build tooling has never treated the map as a first-class output, which means the plumbing is yours. And beyond a few dozen entries, maintaining a map is real work rather than a rounding error. For an application with a genuinely large module graph, the bundler is still doing something for you that you would have to replace deliberately.

The Point

The objection was correct in its premise and wrong in its conclusion. Cache invalidation is neither optional nor free, and it does not disappear when the compile step does.

What happens instead is that it changes owner. It moves out of a factory that reprints the whole edition because one word changed on page four, and into a catalogue where somebody corrects a single card. Both are work. Only one of them scales with the size of everything you did not touch.

A build step compiles. A map remembers. The difference shows up in what your users have to download when you fix a typo.