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.

The caches between a page and its module A request for a module first reaches the service worker, which can answer from Cache Storage. Behind it sits the HTTP cache and then the network. Separately, the engine keeps compiled machine code keyed to the response, so a cached response can also skip compilation. page fetch(app-3f9a1c.wasm) instantiateStreaming on the response service worker + Cache Storage precached at install; offline; explicit updates HTTP cache Cache-Control driven; may evict under pressure network / CDN only on first visit or update compiled-code cache machine code keyed to the response; skips compile

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.

A release rolling out through the service worker The page loads with the old service worker, which serves the old module from cache. The browser finds a new worker script, installs it and precaches the new hashed module. On activation the old cache is deleted, and the next page load gets the new module from cache. page old SW new SW Cache Storage fetch app_bg-3f9a1c.wasm cached response (old version) install: addAll(new hashed files) activate: delete precache-old next load: fetch app_bg-9d40b7.wasm → cached

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 type only with the service worker. The worker built a new Response without the content type. Return the cached response unchanged.
  • Users stuck on an old version. The new worker waits until all tabs close. Use skipWaiting and clients.claim deliberately, 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 instantiateStreaming rejects. 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.

Time from request to compiled 3.1 MB module on a phone First visit over 4G, a repeat visit served from the service worker cache with a cold compiled-code cache, and a repeat visit with the compiled-code cache warm. ms until the module is compiled first visit, network 1,900 ms repeat, SW cache, cold code cache 240 ms repeat, SW cache, warm code cache 70 ms

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.

← Back to Module Caching & Startup Performance