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.
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.
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.brserved 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. immutablewith reload still re-requests in old browsers. Harmless — older browsers revalidate on reload. Modern Chrome, Firefox and Safari honourimmutable.Varymissing for precompressed files. When serving Brotli and gzip variants, sendVary: Accept-Encodingso 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.
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.
Related
- Preloading Wasm with link rel=preload — getting the first download started early.
- Compressing Wasm with Brotli for delivery — the bytes these headers cache.
- Deploying Wasm to Cloudflare Workers — header configuration at the edge.
- Building a Rust Wasm app with Trunk — a build that hashes names for you.
← Back to Module Caching & Startup Performance