Caching Compiled Wasm Modules in IndexedDB

This guide stores a compiled WebAssembly.Module in IndexedDB and restores it on subsequent visits, so a returning user pays neither the download nor the compilation — only a few milliseconds of deserialisation before the module is ready to instantiate.

The mechanism rests on one fact: a WebAssembly.Module is a structured-cloneable object. That means IndexedDB can store it directly, without you serialising anything, and the browser can hand back a compiled artifact rather than bytes. On a large module this is the single largest startup improvement available, and it takes about forty lines of code.

What the second visit skips On the first visit the binary is fetched, compiled, instantiated, and the compiled module is written to IndexedDB. On later visits the stored module is read back and instantiated directly, removing both the network request and the compilation. first visit fetch 250 KB compile instantiate store the Module later visits read back instantiate ready — no network request, no compilation The stored object is the compiled module, not the bytes — which is why this removes far more than an HTTP cache does. It is also why the entry is engine-specific and must be treated as a hint that may be rejected.

Prerequisites

  • [ ] IndexedDB available — every browser has it, but private-mode quotas vary
  • [ ] A content hash for the binary, ideally already in its filename
  • [ ] A working non-cached path, because a cache miss must be ordinary rather than fatal
  • [ ] Somewhere to put the write so it does not delay first paint

Step-by-step procedure

1. Open a store

function openDb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open("wasm-cache", 1);
    req.onupgradeneeded = () => req.result.createObjectStore("modules");
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

2. Wrap get and put

function idb(db, mode, fn) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction("modules", mode);
    const req = fn(tx.objectStore("modules"));
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

const getModule = (db, key) => idb(db, "readonly", (s) => s.get(key));
const putModule = (db, key, mod) => idb(db, "readwrite", (s) => s.put(mod, key));

3. Key by content, not by name

const WASM_URL = "/wasm/kernel-4b7e0d.wasm";   // hash already in the filename
const KEY = WASM_URL;                          // so the URL is a content key

If your filenames are not hashed, compute a key from the bytes instead — but hashed filenames are better, because they let you decide before fetching anything.

4. Load through the cache

export async function loadModule() {
  const db = await openDb().catch(() => null);

  if (db) {
    try {
      const cached = await getModule(db, KEY);
      if (cached instanceof WebAssembly.Module) return { module: cached, hit: true };
    } catch { /* fall through and compile */ }
  }

  const module = await WebAssembly.compileStreaming(fetch(WASM_URL));
  if (db) {
    // after the caller is running, not before — see step 5
    queueMicrotask(() => putModule(db, KEY, module).catch(() => {}));
  }
  return { module, hit: false };
}

5. Instantiate, then store

Storing costs time on the visit that populates the cache. Defer it until after the page is interactive so the first visit is not made slower to benefit the second:

const { module, hit } = await loadModule();
const instance = await WebAssembly.instantiate(module, imports);
console.log(hit ? "module cache hit" : "compiled fresh");

6. Clean up old entries

async function evictOthers(db, keepKey) {
  const keys = await idb(db, "readonly", (s) => s.getAllKeys());
  await Promise.all(
    keys.filter((k) => k !== keepKey)
        .map((k) => idb(db, "readwrite", (s) => s.delete(k)))
  );
}

Without this, every deploy leaves a stale compiled module behind and the store grows without bound until the browser evicts the whole origin — which also takes the entry you wanted.

Expected output

Instrumenting both paths shows the difference plainly:

// first visit
fetch      82.4 ms
compile   108.7 ms
instantiate 1.3 ms
compiled fresh

// second visit
idb read    4.1 ms
instantiate 1.2 ms
module cache hit

Two hundred milliseconds becomes five. The instantiate cost is unchanged, because instantiation allocates memory and binds imports regardless of where the module came from — which is the reminder that this technique targets compilation, not the whole startup.

Where the time goes on each visit The uncached path is dominated by download and compilation. The cached path replaces both with a short IndexedDB read, leaving instantiation as the only remaining cost of consequence. uncached fetch compile 192 ms cached 5 ms — an IndexedDB read plus instantiation The saving is proportional to compile time, so it grows with binary size — the modules that hurt most benefit most. Instantiation is untouched: it allocates memory and binds imports whatever the module's provenance.

Gotchas

The stored value comes back as something other than a Module. Some browsers, or private modes, refuse to structured-clone compiled modules. The instanceof check above turns that into an ordinary miss rather than a crash.

A browser update makes every entry unusable. Compiled artifacts are engine-version-specific. Instantiation will fail; catch it, delete the entry, and compile fresh. Do not assume a stored value that reads back successfully will also instantiate.

Storage quota is exceeded. The write rejects. Treat it as non-fatal — the module still works, it just will not be cached — and make sure evictOthers is running so you are not the cause.

The cache never hits. Usually the key differs between write and read because it includes something volatile like a timestamp or a full URL with query parameters. Log the key on both paths once.

First visit got slower. The store happened before the page was interactive. Defer it.

Every cache failure is an ordinary miss A database that will not open, a stored value that is not a module, an entry that fails to instantiate after a browser update, and a quota-exceeded write all lead to the same place: compile from bytes and carry on. None of them should surface to the user. db will not open value is not a Module stale after an update compile from bytes the module works A cache that can fail loudly is worse than no cache — every path here must end in the working fallback.

Performance note

The saving is proportional to compile time, which scales roughly with binary size. A 50 KB module compiles in a few milliseconds and gains little; a 2 MB module can compile for hundreds of milliseconds and gains almost all of it. Measure before adopting: for small modules the code is not worth the maintenance.

The read itself costs a few milliseconds and happens off the critical rendering path if you request it early. Where it can bite is on a cold IndexedDB — the first open of a database in a session has its own latency — so opening the database once at startup, in parallel with other work, is worth the small amount of extra structure.

Versioning the cache alongside the code

A content-hashed filename handles the common case, but two situations need explicit versioning. The first is a change in how the module is used rather than what it contains — a new import object shape, a different memory configuration — where the binary is identical and the cached module is still correct, but the surrounding code is not. The second is a bug in the caching layer itself, where you want every client to discard what it stored.

A version prefix in the key covers both, and costs nothing:

const CACHE_VERSION = 3;                       // bump to invalidate every client
const KEY = `v${CACHE_VERSION}:${WASM_URL}`;

Pair it with the eviction sweep so old entries do not accumulate: after a successful read or write, delete every key that does not match the current prefix. That keeps storage proportional to one module rather than to your deploy history, which matters on mobile where the quota is modest and eviction is all-or-nothing for the origin.

Measuring whether it is actually working

A cache that silently never hits is worse than no cache, because it costs a write on every visit and delivers nothing. Instrument the outcome so a miss is visible:

const t0 = performance.now();
const { module, hit } = await loadModule();
metrics.event("wasm.module.load", {
  hit,
  ms: Math.round(performance.now() - t0),
});

In a healthy deployment the hit rate rises quickly after a release and stays high until the next one. A rate that never rises means the key differs between write and read — check for a query string, a timestamp, or an absolute URL that varies. A rate that starts high and collapses means storage is being evicted, which on constrained devices is normal and worth knowing about rather than fighting.

The number that justifies the code is the difference in load time between the two paths. If a hit saves five milliseconds because the module is small, the maintenance is not worth it; if it saves two hundred because the module is large, it is one of the highest-value changes available. Both are visible in the same event, which is why recording the duration alongside the hit flag is worth the extra field.

Frequently Asked Questions

Is this the same as the browser’s own code cache? No. Browsers may cache compiled code for stable URLs at their own discretion, and you cannot observe or depend on it. This technique is explicit and under your control, and the two are complementary.

Should I cache the instance too? You cannot — an Instance holds live memory and is not cloneable. Only the Module is, which is fine: instantiation is the cheap phase.

Does this work in a worker? Yes, and it is a good pairing: compile in a worker, store from there, and transfer the Module to the main thread. See compiling Wasm in a worker.

Does this help a single-page application that never reloads? Only across sessions, which is still the common case — users close tabs and come back. Within one session the module is already compiled and held in memory, so the cache contributes nothing until the next visit.

Is localStorage an alternative? No — it stores strings only, and a compiled module is not serialisable to one. IndexedDB’s structured clone is what makes this technique possible at all, which is why the API is more ceremonious than the task appears to warrant.

Does the cache work in private browsing? Usually, with a smaller quota and a lifetime limited to the session, and in some browsers not at all. The fallback path handles both cases without special-casing, which is the reason to write it as an ordinary miss rather than an error.

← Back to Module Caching & Startup Performance