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 .wasm you 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.compileStreaming available — 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

Four phases, four different fixes Download responds to compression and caching. Compilation responds to binary size, streaming and reuse of a cached compiled module. Instantiation responds to the declared memory size. The first call includes tier-up, which no amount of loading work removes. 1 · download 2 · compile + validate 3 · instantiate 4 · first call + tier-up levers brotli, immutable caching preload hint, CDN levers smaller binary · streaming overlap reuse a cached compiled Module — the big one lever smaller initial memory lever none at load time — this is execution, not startup Measure the split before optimising: teams routinely shrink a binary to fix a problem that was entirely in phase 4. On a repeat visit with a compiled-module cache, phases 1 and 2 can both disappear.

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

  1. 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 });
  2. Serve the binary properly. Content-Type: application/wasm, brotli-compressed, with a hashed filename and Cache-Control: immutable. Without the correct MIME type the streaming path is not even available.

  3. Use streaming compilation. compileStreaming and instantiateStreaming overlap compilation with download, so compilation finishes shortly after the last byte instead of starting then.

  4. Cache the compiled module. Store the Module in IndexedDB keyed by the binary’s content hash and read it back on subsequent visits.

  5. 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.

  6. 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.

  7. Re-measure. Each of these interacts with the others; the ordering of what dominates changes as you fix things.

The caching hierarchy

What each cache level removes HTTP caching removes the download but not the compile. Storing a compiled Module in IndexedDB removes both on a repeat visit. The engine may additionally keep its own code cache, which you influence only indirectly by keeping URLs stable. HTTP cache — removes the download hashed filename + immutable · costs nothing to set up · compilation still happens on every load IndexedDB module cache — removes the compile too store the WebAssembly.Module itself, keyed by content hash · the largest repeat-visit win available to you the engine's own code cache — not under your control browsers may cache compiled code for stable URLs · you influence it only by not changing the URL unnecessarily Set up the first two deliberately; treat the third as a bonus that rewards stable, content-hashed URLs.

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.

Optimise the two visits separately A first visit pays download and compilation and responds to compression, size reduction and preloading. A repeat visit should pay neither, which is what a compiled-module cache achieves. Treating them as one problem leads to optimising the wrong one. first visit download + compile dominate levers: compression, size, streaming, preload this is where most effort goes repeat visit should pay neither lever: a compiled-module cache the largest single win available For an application people return to daily, the right-hand column is the one that matters — and it is the one most teams never implement.

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

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.

← Back to WebAssembly Core Concepts & Browser Runtime