Reducing Wasm Cold-Start Latency

This guide addresses the visit where nothing is cached: the binary has to arrive, compile and instantiate before anything happens. It is an ordered checklist, because the steps interact and doing them in the wrong order wastes effort on a phase that was never the problem.

Cold start is the load that shapes a user’s first impression and the one most often measured badly. The order below starts with the changes that cost nothing and cannot hurt, then moves to the ones that require design decisions.

Work down the list, re-measuring after each step Correct headers and streaming compilation cost nothing and always help. Shrinking the binary requires build work. Deferring or splitting the module requires design changes. Each step should be followed by a measurement, because the dominant phase changes as you fix things. 1 · free, and always correct application/wasm · brotli · immutable caching on a hashed filename · instantiateStreaming 2 · build work, no design change opt-level=z · LTO · strip · wasm-opt -Oz --converge · drop unused features 3 · design change defer the module behind an interaction · split it · move compilation to a worker Re-measure between rows. Teams routinely do row 3 for a problem that row 1 would have removed.

Prerequisites

  • [ ] A repeatable cold-start measurement — same page, same throttling, cache disabled
  • [ ] Access to the response headers your host actually sends
  • [ ] A build you can rerun with different flags
  • [ ] The phase breakdown from a first measurement, so you know what you are fixing

Step-by-step procedure

1. Measure the phases before changing anything

const t0 = performance.now();
const { module } = { module: await WebAssembly.compileStreaming(fetch(URL)) };
const t1 = performance.now();
const instance = await WebAssembly.instantiate(module, imports);
console.log({ fetchAndCompile: t1 - t0, instantiate: performance.now() - t1 });

2. Fix the headers

curl -sI https://example.com/wasm/kernel-4b7e0d.wasm \
  | grep -Ei 'content-type|content-encoding|cache-control'
# content-type: application/wasm
# content-encoding: br
# cache-control: public, max-age=31536000, immutable

Any of those three missing is a free win waiting. The MIME type in particular gates streaming compilation entirely.

3. Use the streaming API

// good — compiles as the bytes arrive
const { instance } = await WebAssembly.instantiateStreaming(fetch(URL), imports);

// avoid when the source is a fetch
const bytes = await (await fetch(URL)).arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, imports);

4. Shrink the binary

wasm-opt kernel.wasm -Oz --converge --strip-debug -o kernel.opt.wasm
brotli -q 11 -c kernel.opt.wasm | wc -c

Compile time scales with code size, so this improves the download and the compile together. Measure the compressed number — that is what travels.

5. Defer what the first screen does not need

button.addEventListener("click", async () => {
  const { init } = await import("./wasm/kernel.js");   // fetched now, not at load
  await init();
});

Moving a module behind the interaction that needs it removes its cost from cold start entirely. This is the largest lever available and the one that requires a real decision, because it trades first-load speed for a delay at first use.

6. Trim the initial memory

wasm2wat kernel.wasm | grep '(memory'
# (memory (;0;) 256 4096)   → 16 MB reserved at instantiation

A large initial reservation costs instantiation time and memory footprint. Reserve what the workload actually needs rather than a round number chosen early.

7. Re-measure and stop when the remaining time is not yours

Expected output

A worked example on a throttled connection, applying the checklist in order:

baseline                         fetch 410 ms  compile 190 ms  instantiate 4 ms
+ brotli + application/wasm      fetch 140 ms  compile 188 ms  instantiate 4 ms
+ instantiateStreaming           fetch+compile 205 ms          instantiate 4 ms
+ wasm-opt -Oz --converge        fetch+compile 138 ms          instantiate 4 ms
+ initial memory 256 → 32 pages  fetch+compile 138 ms          instantiate 1 ms

Note what happened at step three: streaming did not make either phase faster, it made them overlap, so the total dropped even though neither number improved on its own. That is the clearest illustration of why totals matter more than phase timings when the phases can run concurrently.

Each step, and what it removed Compression removes most of the download. Streaming overlaps the remaining download with compilation. Size optimisation reduces both. Trimming the initial memory removes most of the instantiation cost, which was small to begin with. baseline 604 ms + compression 332 ms + streaming 209 ms — the two phases now overlap + smaller binary 139 ms Deferring the module behind an interaction would remove all of it from cold start — at the cost of a wait at first use.

Gotchas

Compression is configured but the file is not compressed. Many hosts compress by MIME type from a list that omits application/wasm. Check the response, not the configuration.

instantiateStreaming silently falls back. A try/catch around it that quietly uses the buffered path hides a misconfigured server forever. Log the reason before falling back.

Optimising the binary makes no difference to the total. Then the time was in the network, and the compressed size barely moved because the code compressed well already. Check compressed bytes rather than raw ones.

Instantiation is slow. Almost always a large declared memory, or real work in a start function. Both are visible in the text format.

The numbers change every run. Cold-start measurement needs consistent throttling and a genuinely cold cache. Use a fresh profile or the “disable cache” option, and take a median of several runs.

Deferring moves the cost rather than removing it Loading the module during startup makes the page interactive later. Deferring it behind the interaction that needs it makes startup fast and puts a wait at the moment of first use, which is a better trade only when that interaction is uncommon or can show progress. loaded eagerly module load interactive here, for everyone deferred interactive immediately module load, on first use the user clicks Deferring is right when most sessions never use the feature, and wrong when nearly all of them do within seconds. In the borderline case, defer and preload: the download starts early, but nothing blocks on it.

Performance note

The ceiling on all of this is the first call, which is not a loading cost at all. A module that is ready in 140 ms and then spends 300 ms in its first invocation — tier-up plus actual work — has a startup problem that no amount of header tuning will fix. Establish which side of that line you are on before investing further.

The other ceiling is the network on a cold cache. A 250 KB binary on a slow connection takes what it takes; the only remaining levers are shipping less of it or not shipping it during the initial load at all. That is why deferring is the last step in the checklist rather than the first — it is the most effective and the most invasive.

Splitting one module into two

When deferring the whole module is not possible — the page needs some of its functionality immediately — splitting it often is. The pattern is to separate the module into a small core that the first screen needs and a larger extension loaded on demand.

Concretely, an editor might need syntax highlighting on first paint and a formatter only when someone presses a key combination. Compiled together, both pay for the other’s compile time on every visit; compiled separately, the first screen loads a fraction of the bytes:

before   editor.wasm        480 KB   loaded eagerly, 190 ms compile
after    highlight.wasm     110 KB   loaded eagerly,  42 ms compile
         format.wasm        370 KB   loaded on demand

The cost is a second artifact to build and a boundary between the two halves, which is only worth it if the split is natural. Where the two halves share substantial code the duplication can eat the saving — each module carries its own copy of anything it uses, since they have separate memories and no shared linking. Measure the two artifacts before committing: if their combined size is much larger than the original, the split is in the wrong place.

Setting an expectation for what is achievable

It helps to know roughly what good looks like before deciding whether more work is justified. On a mid-range phone over a typical mobile connection, with everything in this checklist applied:

compressed binary   80 KB      → download ≈ 120 ms
compile                        → ≈ 60 ms
instantiate                    → ≈ 2 ms
total to ready                 → under 200 ms

A module in that range is effectively invisible in a page that also loads JavaScript, images and fonts. A 500 KB module on the same connection lands closer to 700 ms, which is noticeable but usually acceptable behind a loading state. Beyond roughly a megabyte the module becomes the dominant cost of the page, and at that point the productive move is almost always to ship less of it — by splitting, by deferring, or by removing code the size analysis says is not pulling its weight.

Knowing those bands prevents both mistakes: accepting a two-second start because “Wasm is like that”, and spending a week shaving thirty milliseconds off a module that was already fast enough.

Frequently Asked Questions

Should I inline a small module as base64 to avoid a request? Only for genuinely tiny modules. Base64 inflates the bytes by a third and cannot stream-compile, so the break-even is low — a few kilobytes at most. See the inlining discussion in bundling Wasm ESM with Vite.

Does HTTP/2 or HTTP/3 change any of this? It reduces connection overhead, which helps a page with many resources more than it helps one large binary. The phases and their levers are unchanged.

What about a service worker? It can serve the bytes from a cache without a network round trip, which addresses phase one. It does not address compilation — for that you need a compiled-module cache.

Does a smaller binary always compile faster? Broadly yes — compile time scales with the amount of code — but not proportionally, since some constructs cost more to validate and optimise than others. Measure rather than extrapolating: a 20% size reduction usually buys somewhat less than 20% of the compile time.

Does HTTP caching help the first visit? No, by definition — but it is still worth configuring before measuring, because an uncached repeat load will otherwise contaminate the numbers you are trying to read.

Is there a single most valuable change? Correct headers, because they are free and gate everything else.

← Back to Module Caching & Startup Performance