Module Caching & Startup Performance
Execution speed is why teams reach for WebAssembly; startup time is why some of them regret it. A module has to be downloaded, compiled and instantiated before a single exported function runs, and on a mid-range phone with a cold cache those three phases can add up to more time than the computation they enable saves. This area is about measuring that cost properly and then removing as much of it as the platform allows.
The good news is that each phase has a specific, well-understood lever. Download responds to compression and to caching headers. Compilation responds to binary size, to streaming, and — the largest win available — to reusing a previously compiled module rather than compiling again. Instantiation is usually cheap unless the module reserves a large memory. Knowing which phase your time is in turns a vague “the module is slow to load” into a specific fix.
Prerequisites
- [ ] A built
.wasmyou can serve with correct headers and a content hash in the filename - [ ] DevTools with the Network and Performance panels, and the ability to throttle
- [ ]
WebAssembly.compileStreamingavailable — every current engine has it - [ ] IndexedDB available in the target browsers, for the compiled-module cache
- [ ] A repeatable measurement: same page, same throttling profile, cache cleared between runs
The four phases and what moves each one
The single largest available win is caching the compiled module rather than the bytes. A
WebAssembly.Module is structured-cloneable, which means it can be stored in IndexedDB and read back
later; a browser that recognises the stored artifact can skip compilation entirely. On a large module
that turns a hundred-millisecond compile into a few milliseconds of deserialisation.
Step-by-step workflow
-
Measure first. Record the four phases explicitly rather than timing the whole load:
const t0 = performance.now(); const res = await fetch("/wasm/kernel.wasm"); const t1 = performance.now(); const module = await WebAssembly.compileStreaming(Promise.resolve(res)); const t2 = performance.now(); const instance = await WebAssembly.instantiate(module, imports); const t3 = performance.now(); console.log({ fetch: t1 - t0, compile: t2 - t1, instantiate: t3 - t2 }); -
Serve the binary properly.
Content-Type: application/wasm, brotli-compressed, with a hashed filename andCache-Control: immutable. Without the correct MIME type the streaming path is not even available. -
Use streaming compilation.
compileStreamingandinstantiateStreamingoverlap compilation with download, so compilation finishes shortly after the last byte instead of starting then. -
Cache the compiled module. Store the
Modulein IndexedDB keyed by the binary’s content hash and read it back on subsequent visits. -
Move compilation off the main thread where the module is large enough that even the streamed compile is visible as a long task — compile in a worker and transfer the
Module. -
Preload deliberately. If the module is needed for the first interaction, a
<link rel="preload">starts the download during HTML parsing rather than after the JavaScript that requests it has run. -
Re-measure. Each of these interacts with the others; the ordering of what dominates changes as you fix things.
The caching hierarchy
Tradeoffs worth knowing
Caching a compiled module is not free. The stored artifact is engine-specific and version-specific, so a browser update invalidates it — your code must handle a cache miss gracefully rather than assuming the stored value is usable. It also consumes storage quota, which on mobile is finite and may be evicted without warning. Both are ordinary conditions rather than errors: the fallback is simply to compile as normal and re-store.
Preloading has its own cost. A preload hint for a module the visitor never uses is a wasted download that competes with resources they do need. Preload only what the first interaction requires, and let everything else load lazily behind a dynamic import.
The most common mistake is optimising the wrong phase. A team that shrinks a binary from 400 KB to 250 KB and sees no improvement has usually discovered that their time was in the first call — tier-up and actual computation — rather than in loading at all. That is why the measurement step comes first in the workflow above and not last.
Gotchas & failure modes
instantiateStreaming rejects with a TypeError. The response was not application/wasm. This
silently costs you the entire streaming benefit if you catch it and fall back, so log the header before
retrying.
The cached module fails to instantiate after a browser update. Expected — compiled artifacts are engine-version-specific. Catch the failure, discard the entry, and compile from bytes.
Storage writes make the first visit slower. Storing the module costs time on the visit that populates the cache. Do it after the module is instantiated and the page is interactive, not before.
Preload fires for a hashed file that no longer exists. The hint was hard-coded rather than generated from the build. Emit it from the same source as the import.
Compilation blocks the main thread despite streaming. Streaming overlaps compilation with download, but the work still happens somewhere. For a large module, move it to a worker.
Serving the binary correctly
Half of the download phase is decided by the server rather than by the build, and the settings involved are small enough to state exhaustively.
Content type. application/wasm, exactly. Streaming compilation checks this header before reading
any bytes, so anything else — application/octet-stream, text/plain, a generic default — silently
costs you the streaming path and forces the slower buffered one. This is the single most common
server-side mistake, and it is invisible unless you look at the header or notice the fallback firing.
Compression. WebAssembly compresses well: brotli at maximum quality typically reaches about a third
of the raw size, gzip a little worse. Many hosts compress by MIME type from a list assembled before
application/wasm existed, so the file arrives uncompressed while the configuration says compression
is on. Check the response rather than the config.
Cache lifetime. With a content hash in the filename the answer is a year and immutable, because
the URL changes whenever the bytes do. Without a hash, no cache policy is safe and the browser
revalidates on every load — which is a good reason to add the hash rather than to tune the policy.
Range requests and CDN behaviour. A CDN that does not understand the content type may still serve it correctly, but some intermediaries rewrite or buffer responses in ways that break streaming. If compilation appears to wait for the full download in production but not locally, the CDN is the first thing to check.
Cross-origin isolation headers, if and only if you need them. COOP and COEP are required for shared memory and threads and cost you every subresource that does not opt in. They belong on the routes that use threading rather than on the whole origin.
A single command confirms the first three, and it is worth running against production rather than staging, since header configuration is exactly the sort of thing that differs between them:
curl -sI -H 'Accept-Encoding: br' https://example.com/wasm/kernel-4b7e0d.wasm \
| grep -Ei 'content-type|content-encoding|cache-control'
Three correct lines there remove most of the download phase as a concern, and they cost nothing per request. It is the cheapest work in this whole area, which is why it is the first step of the checklist rather than a footnote to it.
Verification
Confirm the wins are real by measuring the same page twice — once cold, once with the cache populated:
curl -sI https://example.com/wasm/kernel-4b7e0d.wasm | grep -Ei 'content-type|content-encoding|cache-control'
// second visit, with a populated module cache
{ fetch: 0.4, compile: 3.1, instantiate: 1.2 } // vs { fetch: 82, compile: 108, instantiate: 1.3 }
A repeat visit that still shows a hundred-millisecond compile means the cache is not being hit — check the key, and check that the stored value is being read before the fetch rather than after it.
Budgets, and what to promise
Startup work is easy to do endlessly and hard to know when to stop, so it helps to set a budget before starting and to express it in terms a product decision can use.
The useful framing is time to first useful result, measured from navigation. That number includes the page’s own JavaScript, the module’s download and compilation, and the first call — which is the whole point, because the user does not care which of those was responsible. Pick a target that matches the interaction: an editor whose module powers the initial render needs the module ready before first paint, while a module behind an “export” button has until the user clicks it, which is usually seconds rather than milliseconds.
From that target the individual budgets fall out. Suppose the goal is a useful result within 1,500 ms on a mid-range phone over a throttled connection, and the page’s own JavaScript already consumes 600 ms. That leaves roughly 900 ms, of which the first call needs perhaps 200 ms, leaving 700 ms for download plus compile. At typical throttled bandwidth that caps the compressed binary at a few hundred kilobytes — which is a concrete constraint the team can design against, rather than “make it smaller”.
Three habits keep the budget meaningful once it is set. Measure on a device representative of your audience rather than a developer laptop, because compilation is CPU-bound and the gap between the two is often three or four times. Measure cold, with the cache disabled, since that is the visit that forms an impression. And record the number over time, because startup regresses gradually — a dependency here, a feature there — in a way that no single change ever appears responsible for.
It is also worth deciding explicitly what you are not going to optimise. If the module is behind a rarely used feature, its startup cost may simply not matter, and the right answer is to defer it and spend the effort elsewhere. Being deliberate about that keeps the work proportionate: the goal is a page that feels immediate, not a module that loads in the fewest possible milliseconds regardless of whether anyone is waiting for it.
In this guide
- Caching compiled Wasm modules in IndexedDB — storing and restoring a compiled Module.
- Reducing Wasm cold-start latency — the ordered checklist for a first visit.
- Preloading Wasm with link rel=preload — starting the download earlier, correctly.
- Compiling Wasm in a worker to free the main thread — moving the long task off the UI thread.
- Measuring Wasm compile time in DevTools — finding the phase that actually costs.
Frequently Asked Questions
Does the browser cache compiled WebAssembly automatically? Sometimes, for stable URLs, at the engine’s discretion — and you cannot rely on it or observe it directly. An explicit IndexedDB cache is the only version you control.
Is streaming instantiation always better?
When the bytes come from a fetch, yes. When they come from somewhere else — an inlined asset, a
worker message, a cached ArrayBuffer — there is nothing to stream and the buffered API is correct.
See streaming vs ArrayBuffer instantiation.
How small does a module have to be for startup to stop mattering? There is no threshold, only a ratio: startup matters when it is significant relative to how long the page has before the user needs the feature. A 200 KB module loaded during a splash screen is invisible; the same module blocking a click is not.
Can I share a compiled module between tabs?
Not directly. Each realm compiles or restores its own, though both may hit the same IndexedDB entry.
A SharedWorker can hold one instance that several tabs message, which is a different architecture
rather than a caching trick.
Instantiation, and the memory reservation
Instantiation is the phase people ignore, usually correctly — it is milliseconds where the others are tens or hundreds. The exception is worth knowing, because when it does cost, the cause is always the same one.
Instantiating a module allocates its linear memory at the declared initial size, binds every import,
and runs the start function if one exists. The binding is trivial and the start function is usually
absent, so what remains is the allocation. A module declaring an initial size of sixteen pages costs
nothing to instantiate; one declaring four thousand pages is asking the engine for 256 MB up front, and
that is visible.
wasm2wat kernel.wasm | grep '(memory'
# (memory (;0;) 256 4096) → initial 16 MB, maximum 256 MB
The first number is what instantiation pays for. Toolchains frequently default it higher than a module actually needs — an Emscripten build defaults to 16 MB regardless of the program — and trimming it to the measured high-water mark is a small change with two benefits: faster instantiation, and a smaller memory footprint per instance, which matters when several instances exist in workers.
The counter-argument is growth: reserving too little means memory.grow runs during the workload,
which for an unshared memory detaches every view the JavaScript side is holding. That is a correctness
hazard rather than a performance one, and it is why the sizing exercise is worth doing properly —
measure the peak over representative inputs, add headroom, and declare that.
Where several instances exist, the arithmetic multiplies. Four workers each holding a 256 MB reservation is a gigabyte of address space for a workload that may touch a fraction of it. Sizing per instance rather than copying one number into every build is the difference between a page that works on a mid-range phone and one that does not.
Does a service worker help with any of this? It addresses the download phase and only the download phase. Serving the bytes from a service-worker cache removes the network round trip, which on a slow connection is a substantial part of cold start, but the module is still compiled from those bytes on every load. For the compile phase you need a compiled-module cache; the two are complementary and a page can use both.
How does this interact with a bundler? Mostly by staying out of the way. The bundler’s job is to emit the binary as a hashed asset and rewrite the import so the URL is correct; everything in this area happens after that. The one place they interact is inlining — a bundler that inlines a small binary as base64 removes the separate request and with it the ability to stream-compile or to cache the module by URL, so a size threshold set too high quietly disables several of these techniques at once.
Which of these techniques should a small project do first? The headers, then streaming, then nothing else until a measurement says otherwise. Those two are a configuration change and a one-line API change, they cannot make anything worse, and together they remove most of what is fixable in a typical first-visit load. Everything after that costs code, and code is worth spending only where the phase breakdown says the time actually is.
Related
- Wasm instantiation lifecycle — the phases this area optimises.
- Wasm optimization flags & size reduction — making the binary smaller in the first place.
- Local development server configurations — serving with the headers streaming requires.
- Debugging and profiling Wasm modules — where these phases appear in the Performance panel.