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.
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.
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.
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.
Related
- Module caching & startup performance — the phases and the full set of levers.
- Preloading Wasm with link rel=preload — starting the download sooner.
- Wasm optimization flags & size reduction — the build-side work in step four.
- Local development server configurations — getting the headers right locally first.
← Back to Module Caching & Startup Performance