Setting Cache-Control Headers for Wasm

This page answers one question: which Cache-Control headers should .wasm files and the files that reference them carry, so that returning users never download a module twice — and never run a stale one?

Prerequisites

  • [ ] Control over response headers on your server, CDN or hosting platform.
  • [ ] A build that can put content hashes in file names, or the willingness to add one.
  • [ ] DevTools’ Network panel to verify behaviour.

Two kinds of file, two policies

Caching policy for a WebAssembly app follows one principle: a URL whose content can never change may be cached forever; a URL whose content can change must be checked. Content-hashed files — app_bg-3f9a1c.wasm, app-8a21f0.js — fall in the first group, because a new build produces a new name. Entry points — the HTML document, a service worker script, any file loaded by a fixed name — fall in the second, because they are how the browser learns which hashed files are current.

Getting this split right is what lets a large module be downloaded once and reused for months, while every deploy still reaches users on their next visit. Getting it wrong produces one of two failures: modules re-downloaded on every visit because they were marked no-cache, or users running an old module for days because an unhashed URL was cached with a long lifetime.

Cache policy by kind of file Content-hashed files such as modules and glue are cached immutably for a year. Entry points such as the HTML, the service worker and any fixed-name loader are revalidated on every use so new releases are picked up. hashed assets (.wasm, .js, .css) name changes when content changes public, max-age=31536000, immutable never revalidated, never re-downloaded cache forever entry points (HTML, sw.js, loader.js) fixed name, content changes per release no-cache (revalidate each time) cheap 304 when unchanged always check

Step 1 — make module URLs content-hashed

Long caching is only safe with hashed names, so start there. Bundlers do it by default for assets they emit; Trunk does it for Rust front ends; wasm-pack output can be hashed by the bundler that consumes it. The details are in versioning Wasm files with content hashes. Check the built output: every .wasm file name should contain a hash, and every reference to it should use that name.

ls dist/assets/ | grep -E '\.wasm$'
# app_bg-3f9a1c4d.wasm   codec_bg-77e0d2aa.wasm

Step 2 — set immutable caching for hashed files

Cache-Control: public, max-age=31536000, immutable

max-age=31536000 is one year, the conventional maximum. immutable tells browsers not to revalidate the file even when the user reloads the page, which otherwise triggers a conditional request for every resource. public allows shared caches such as CDNs to store it. In nginx:

location ~* "-[0-9a-f]{8,}\.(wasm|js|css)$" {
    add_header Cache-Control "public, max-age=31536000, immutable";
    types { application/wasm wasm; }
}

The regular expression matches only files with a hash in their name, so an accidentally unhashed app.wasm does not receive a year-long lifetime.

Step 3 — revalidate entry points

Cache-Control: no-cache

no-cache allows the browser to store the response but requires it to check with the server before each use. With an ETag or Last-Modified validator, an unchanged file costs a small 304 Not Modified round trip; a changed one is downloaded. Apply it to the HTML, to service worker scripts, and to any loader script with a fixed name:

location = /index.html { add_header Cache-Control "no-cache"; }
location = /sw.js      { add_header Cache-Control "no-cache"; }
location /             { try_files $uri /index.html; add_header Cache-Control "no-cache"; }

Avoid no-store for these files unless they contain private data; it forbids storage entirely and makes back/forward navigation slower.

A returning visit with correct headers The browser revalidates the HTML with a conditional request and gets a 304 or a new document. The HTML references hashed JavaScript and Wasm, which are served from the HTTP cache without any request because they are immutable. Only after a deploy does the HTML change and point at new hashed files. GET / (no-cache) 304 if unchanged app-8a21f0.js immutable: from cache app_bg-3f9a1c.wasm immutable: from cache compile code cache may skip it After a deploy the HTML changes, references new hashed names, and only those new files are downloaded.

Step 4 — configure the CDN or edge platform

CDNs often apply their own defaults, and many ignore or rewrite origin headers unless configured. Make sure the CDN forwards your Cache-Control to browsers and uses it for its own caching. On platforms with configuration files, set the headers there:

# _headers (Netlify, Cloudflare Pages)
/assets/*.wasm
  Cache-Control: public, max-age=31536000, immutable
  Content-Type: application/wasm
/index.html
  Cache-Control: no-cache

For object storage behind a CDN, set Cache-Control and Content-Type as object metadata at upload time; the CDN passes them through. Purging the CDN cache is then only ever needed for entry points, and with no-cache even that is rarely necessary. The full set of headers a production Wasm response should carry is in serving Wasm files with the right headers.

Step 5 — verify with DevTools and curl

curl -sI https://example.com/assets/app_bg-3f9a1c4d.wasm | grep -iE 'cache-control|content-type|age'
curl -sI https://example.com/ | grep -i cache-control
cache-control: public, max-age=31536000, immutable
content-type: application/wasm
age: 51233
cache-control: no-cache

In the browser, reload a page you have visited before. The .wasm request should show (memory cache) or (disk cache) with no network time; the HTML should show a 304 or a 200 with a small transfer.

Why heuristic caching causes stale modules

Responses without any caching headers are still cached: browsers apply heuristic freshness, typically a tenth of the time since the file’s Last-Modified date. A module last modified a month ago may be considered fresh for three days without any check. That is how users end up running an old module with new glue after a deploy that reused file names — the browser never asked the server. The fix is not to rely on heuristics: give every response an explicit policy, either immutable with a hashed name or no-cache with a fixed one. There is no safe middle ground of “cache for an hour” for files whose names do not change, because an hour is long enough to mix versions.

Rolling out a header change safely

Changing caching headers on a live site has a delay built in: responses already in users’ caches keep the policy they were served with until they expire or are revalidated. If the old policy was a long lifetime on an unhashed module, users will keep that module until it expires no matter what the new headers say. Plan the change in two stages. First, ship hashed file names and correct headers together, so that the HTML — which should already be revalidated — starts pointing at new, correctly cached URLs; the old unhashed URLs simply stop being requested. Second, after the old lifetime has passed, remove any special handling for the old names.

The reverse change deserves the same care. Moving a file from no-cache to immutable is safe only when its name is guaranteed to change with its content, which means the build and the deployment must agree. A deploy pipeline that uploads new hashed files before uploading the HTML that references them, and never deletes the previous release’s files immediately, avoids a window where a cached HTML document references files that no longer exist. Keeping the last two or three releases’ assets on the server costs little and makes rollbacks and long-lived tabs safe.

Expected output

On a repeat visit after no deploy, the Network panel shows one small revalidation request for the HTML and nothing else from the network; after a deploy, it shows the new HTML and downloads only the files whose hashes changed.

Gotchas

  • Different headers for compressed variants. A .wasm.br served with its own rules can end up with a different lifetime from the uncompressed file. Apply the policy by the requested URL, not the file served.
  • A year-long lifetime on an unhashed file. Users keep the old version until it expires. Restrict immutable caching to hashed names.
  • CDN overrides headers. Some CDNs set their own Cache-Control. Check the response at the edge, not just at the origin.
  • immutable with reload still re-requests in old browsers. Harmless — older browsers revalidate on reload. Modern Chrome, Firefox and Safari honour immutable.
  • Vary missing for precompressed files. When serving Brotli and gzip variants, send Vary: Accept-Encoding so caches keep them apart.

Performance note

For an app with a 2.8 MB module (0.9 MB Brotli), moving from heuristic caching to immutable hashed URLs removed a revalidation request per visit and, more importantly, kept the compiled-code cache valid across visits. On a phone over 4G, the repeat-visit time to an interactive feature dropped from 640 ms to 110 ms.

Repeat-visit time to a ready module by caching policy The same 2.8 MB module on a repeat visit from a phone over 4G, with heuristic caching, with no-cache on the module, and with an immutable hashed URL. ms until the module is ready no-cache on the module (304) 410 ms heuristic caching (expired) 640 ms immutable hashed URL 110 ms

Frequently Asked Questions

Is a year too long? For hashed files, no — the content at that URL never changes, so any lifetime is safe. A year is the conventional maximum browsers respect.

Should the .wasm file be private? Only if it contains user-specific data, which modules almost never do. public lets CDNs cache it.

Does the compiled-code cache depend on these headers? It depends on the cached response being reused. Immutable responses served from cache keep it valid; re-downloaded responses may force a recompile.

Should API responses the module fetches use the same rules? No — data responses have their own freshness requirements. These rules are for static code assets: modules, glue, styles and the documents that reference them.

What about service workers? A service worker can serve modules from its own cache regardless of HTTP headers; the worker script itself should be no-cache. See caching Wasm with a service worker.

← Back to Module Caching & Startup Performance