Caching Wasm with a Service Worker
This page answers one task: make a WebAssembly application load its modules instantly on repeat visits and work offline, by serving
the .wasm files from a service worker cache — without breaking streaming compilation or the browser’s own compiled-code cache.
Prerequisites
- [ ] A site served over HTTPS (or
localhost), since service workers require a secure context. - [ ] Modules with content-hashed file names, so each version has a unique URL.
- [ ] A build step that can list the files to precache.
What a service worker adds over the HTTP cache
Browsers already cache .wasm files in the HTTP cache, and with long Cache-Control lifetimes they are reused across visits. A service
worker adds control. It can download modules ahead of time, before the user needs them; it can guarantee they stay available offline,
instead of being evicted under storage pressure by the HTTP cache’s heuristics; and it can decide exactly when a new version replaces an
old one, so a page never mixes glue from one release with a module from another.
There is a second cache to keep in mind. Browsers cache compiled WebAssembly code — the machine code produced from a module — keyed to the module’s URL and response. Chrome, for example, stores optimized code alongside the response in its cache so a later visit can skip compilation. Responses served from a service worker’s Cache Storage participate in this as well, provided the response is passed through unchanged. A service worker that reconstructs responses or changes their headers can accidentally defeat compiled-code caching, which is one of the things this page is careful about.
Step 1 — precache modules at install
During the service worker’s install event, download every module the app needs and store it. The list comes from the build, with hashed
names so each entry is immutable:
// sw.js
const VERSION = "2026-10-02.1";
const PRECACHE = `precache-${VERSION}`;
const ASSETS = self.__ASSETS ?? [ // injected by the build
"/", "/app-8a21f0.js", "/pkg/app_bg-3f9a1c.wasm", "/pkg/codec_bg-77e0d2.wasm",
];
self.addEventListener("install", (event) => {
event.waitUntil((async () => {
const cache = await caches.open(PRECACHE);
await cache.addAll(ASSETS); // fails the install if any request fails
await self.skipWaiting();
})());
});
cache.addAll fetches every URL and stores the responses with their original headers, including Content-Type: application/wasm. If any
download fails the whole install fails, which is what you want: a half-populated cache is worse than none.
Step 2 — serve modules cache-first
For hashed assets, cache-first is safe — a given URL never changes content — and it is the fastest strategy:
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== location.origin) return;
if (!/\.(wasm|js|css)$/.test(url.pathname)) return;
event.respondWith((async () => {
const cached = await caches.match(event.request);
if (cached) return cached; // the stored Response, headers intact
const response = await fetch(event.request);
if (response.ok) {
const cache = await caches.open(PRECACHE);
cache.put(event.request, response.clone());
}
return response;
})());
});
Return the stored Response object directly. Do not build a new Response from its bytes unless you must; if you do, copy the
Content-Type header explicitly, or instantiateStreaming fails with a MIME-type error — the same failure described in
serving Wasm with the correct MIME type locally.
Step 3 — clean up old versions on activate
Each release has new hashed file names, so old cache entries become garbage. Delete caches from previous versions when the new worker activates:
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k.startsWith("precache-") && k !== PRECACHE).map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
Large modules make this important: a 20 MB model or codec cached per release fills a user’s storage quota quickly if old versions are never removed.
Step 4 — keep glue and module in step
The most common service-worker bug in Wasm apps is a mismatch: new JavaScript glue paired with an old module, or the reverse. wasm-bindgen
glue in particular expects exactly the module it was generated with; a mismatch fails at instantiation with a LinkError about a missing
import, or worse, misbehaves silently. Content-hashed names prevent it if the HTML references the hashed JavaScript and the JavaScript
references the hashed module — every file then points only at files from its own build. Precache them as a set and activate them as a set,
which the install-then-activate cycle above does.
Step 5 — warm the compiled-code cache
On the first visit, the module is downloaded and compiled; on later visits from Cache Storage, Chrome can reuse compiled code if the same response is used again. To make the second visit fast as well, it helps to compile the module once during the first visit even if the feature is not used yet, so optimized code is produced and stored:
// after the page is idle on first visit
requestIdleCallback(async () => {
const response = await fetch("/pkg/codec_bg-77e0d2.wasm"); // served by the SW
await WebAssembly.compileStreaming(response); // populates the code cache
});
Chrome’s heuristics decide when compiled code is cached — typically for modules above a size threshold that have been compiled with the
optimizing tier — so measure on your own modules rather than assuming. The browser-level behaviour is described in
caching compiled Wasm modules in IndexedDB,
which also explains why storing WebAssembly.Module objects yourself is no longer the recommended approach.
Deciding what to precache
Precaching everything is tempting and usually wrong for WebAssembly apps, because modules are large and storage on phones is finite. Divide the app’s modules into three groups. Modules needed on every visit — the core engine, the UI framework’s runtime — belong in the precache, because there is no scenario where the user does not need them. Modules for features most users touch eventually — an export format, an optional codec — are better cached on first use by the cache-first handler above, so a user who never opens the feature never pays for it. And very large, rarely used assets — a 200 MB language model, a full-text index — should be cached only on explicit request, with progress shown and an option to remove them, because silently downloading them would surprise users on metered connections.
Storage is also shared and evictable. Browsers grant each origin a quota that depends on available disk space, and under pressure they may
evict an origin’s storage unless it is marked persistent. For apps whose offline value depends on large cached modules, request persistence
with navigator.storage.persist() after the user has engaged with the feature, and handle the case where cached modules have been evicted
by falling back to the network transparently. The precache is an optimization; the app must keep working without it.
Expected output
In DevTools’ Network panel on a repeat visit, the module’s row shows (ServiceWorker) in the Size column and a few milliseconds of time. Under Application → Cache Storage, the precache contains the hashed module with its headers:
precache-2026-10-02.1
/pkg/app_bg-3f9a1c.wasm content-type: application/wasm 1.84 MB
/pkg/codec_bg-77e0d2.wasm content-type: application/wasm 3.10 MB
With the network disabled (DevTools → Network → Offline), the app still loads and the modules instantiate.
Gotchas
TypeError: Incorrect response MIME typeonly with the service worker. The worker built a newResponsewithout the content type. Return the cached response unchanged.- Users stuck on an old version. The new worker waits until all tabs close. Use
skipWaitingandclients.claimdeliberately, and prompt users to reload when a new version is ready. - Storage quota exceeded. Large modules across several releases. Delete old caches on activate and check
navigator.storage.estimate(). - Opaque responses from a CDN. Cross-origin requests without CORS produce opaque responses, which
instantiateStreamingrejects. Serve modules same-origin or with CORS.
Performance note
For a 3.1 MB codec on a mid-range phone, the first visit took 1.9 s from request to compiled module over 4G. A repeat visit through the service worker took 240 ms when the compiled-code cache was cold and 70 ms when it was warm — the service worker removed the download, the code cache removed the compile.
Frequently Asked Questions
Do I need a service worker if my HTTP caching is good? Not for speed alone — immutable HTTP caching already avoids most downloads. Add a service worker for offline support, guaranteed precaching, or controlled updates.
Can the service worker compile the module for the page? Compiled modules can be posted to workers but not from a service worker to a page in a useful way. Let the page compile; the service worker’s job is the bytes.
Should the service worker cache modules from other origins? Only with CORS, so responses are not opaque. Same-origin hosting is simpler.
Does Workbox handle Wasm?
Yes — Workbox’s precaching and cache-first strategies work for .wasm files and preserve headers. The same rules about hashed names and
version cleanup apply.
Related
- Versioning Wasm files with content hashes — the naming the service worker relies on.
- Setting Cache-Control headers for Wasm — the HTTP cache underneath.
- Reducing Wasm cold-start latency — what to do about the first visit.
- Loading large model weights into linear memory — caching very large assets alongside modules.
← Back to Module Caching & Startup Performance