Caching Compiled Wasm in Node.js
This page answers one task: a Node.js command-line tool or short-lived process loads a large WebAssembly module on every run — a formatter, a linter, a compiler, an image tool — and startup is dominated by compiling it. You want to know which parts of that cost can be cached or avoided, and which cannot.
Prerequisites
- [ ] A Node.js program that compiles a Wasm module at startup (directly or through generated glue).
- [ ] Node 22 or later for the compile-cache API.
- [ ] A way to time startup (
hyperfineorperformance.now()marks).
Where startup time goes
Loading a module in Node involves reading the file, compiling it, instantiating it and running its initialisation, plus loading and compiling the
JavaScript glue around it. V8 compiles WebAssembly in tiers: a fast baseline compiler (Liftoff) produces code quickly, and an optimising compiler (TurboFan)
recompiles hot functions later in the background. Recent V8 versions also compile lazily — functions are compiled on first call rather than all up front —
so the initial WebAssembly.compile mostly validates the module and the cost is spread over the first calls. For a 10 MB module, compilation can still
take tens to hundreds of milliseconds across startup, which matters for a CLI that runs for half a second.
Browsers cache compiled Wasm code between page loads (keyed by the response URL and contents). Node does not have that HTTP-cache path, and it does not
currently provide a stable public API to serialise a compiled WebAssembly.Module to disk and load it in a later process. The practical techniques are
therefore: measure, let lazy and baseline compilation do their job, cache everything else (the JavaScript glue, initialised state), keep processes alive
where you can, and share compiled modules within a process.
Step 1 — measure the phases
const t0 = performance.now();
const bytes = await readFile(wasmPath);
const t1 = performance.now();
const module = await WebAssembly.compile(bytes);
const t2 = performance.now();
const instance = await WebAssembly.instantiate(module, imports);
const t3 = performance.now();
instance.exports.init();
const t4 = performance.now();
console.error({ read: t1 - t0, compile: t2 - t1, instantiate: t3 - t2, init: t4 - t3 });
Run the whole CLI with hyperfine 'node cli.mjs --version' to see total startup, and compare against a run that skips the Wasm load. If compilation is a
small part, look elsewhere first — initialisation and the JavaScript glue are often larger.
Step 2 — enable Node’s compile cache for the JavaScript side
Node 22 added an on-disk compile cache for JavaScript modules. Enable it at the top of the entry point (or with NODE_COMPILE_CACHE=dir):
import module from "node:module";
module.enableCompileCache?.(); // caches compiled JS for later runs
It covers the CLI’s JavaScript, including large generated glue files from Emscripten or wasm-bindgen, which for some tools cost as much as the Wasm itself. It does not cache WebAssembly compilation.
Step 3 — let lazy compilation work for you
V8’s lazy Wasm compilation means functions never called are never compiled. Make sure nothing defeats it: avoid tooling that eagerly touches every
function at startup, and avoid V8 flags that force eager compilation. For CLIs that run briefly, the baseline tier is what runs; optimising compilation of
hot functions happens in background threads and may never finish before the process exits. If profiling shows time spent in compilation threads
competing with the main work, V8 flags such as --no-wasm-tier-up (baseline only) can help very short runs — at the cost of slower code — but flags are
version-specific and should be measured, not assumed.
Step 4 — snapshot initialisation
If initialisation after instantiation is expensive — building tables, parsing embedded data, booting an interpreter — run it at build time with Wizer, which executes the module’s initialiser and writes the resulting memory back into the module’s data segments. At runtime the module starts already initialised; you trade a larger file for skipped work. See snapshotting initialized Wasm with Wizer.
Step 5 — share and keep compiled modules within a process
Within one process, compile once and reuse: pass the WebAssembly.Module to every worker rather than compiling per worker, and keep it in a module-level
variable rather than recompiling per request. For tools that are invoked many times in quick succession — editor integrations, file watchers, test
runners — consider a long-lived daemon that keeps the module loaded and receives requests over a socket or stdin, which turns repeated startup cost into a
one-time cost. Language servers and formatters commonly use this pattern.
When compilation is still too slow
For very large modules where even baseline compilation is a problem, two options remain. Make the module smaller — split rarely used functionality into a
second module loaded on demand, strip debug sections, use size optimisation — since compilation time scales with code size. Or, for tools that do not need
to be JavaScript, run the module in a standalone runtime that supports ahead-of-time compilation: wasmtime compile produces a precompiled .cwasm that
loads in milliseconds, at the cost of shipping a runtime and native artefacts per platform.
Bundled single-file executables
Node’s single executable applications bundle a script and its assets into the Node binary. They can embed the .wasm file as an asset, which removes the
file read but not compilation. They also support startup snapshots for JavaScript state in recent versions; check current documentation for whether your
Node version can include WebAssembly objects in snapshots before relying on it, since support has been limited.
Building a daemon mode safely
A daemon that keeps the module loaded removes repeated startup, but it changes the tool’s failure modes. State now persists between invocations, so the module must be reset or re-instantiated between unrelated requests — a formatter that caches configuration from one project must not apply it to another. Memory grows over the daemon’s life because Wasm memory never shrinks; recycle the instance after a number of requests or when memory passes a threshold, which costs one instantiation from the already compiled module. The client side needs a fallback: if the daemon is not running, is the wrong version, or does not answer within a short timeout, run the work in-process and optionally start a new daemon in the background. Version the protocol and include the tool’s version in the handshake, so an editor that updated the tool does not keep talking to an old daemon. Language servers already solve these problems, and wrapping the Wasm tool in one is often the cleanest route for editor integrations.
Measuring in CI to prevent regressions
Startup regressions creep in through dependency updates that enlarge the glue, new initialisation work, or a toolchain change that disables lazy
compilation. Track startup in CI with hyperfine on a fixed runner type, recording the median of many runs for a trivial invocation such as --version
and for a small realistic input. A budget — for example, “startup under 250 ms on the CI runner” — with a tolerance for runner noise catches large
regressions automatically, and the phase timings from step 1, logged in verbose mode, show which phase grew when the budget fails.
Expected output
hyperfine shows CLI startup dropping from 480 ms to 210 ms: 120 ms saved by the compile cache on a 4 MB generated glue file, 110 ms by a Wizer snapshot of
initialisation, and 40 ms by removing an eager call that touched every exported function; Wasm compilation itself remains about 60 ms and is accepted, with a
daemon mode available for editor integrations.
Gotchas
- Assuming Node caches Wasm like browsers. It does not persist compiled Wasm across processes. Reduce work instead.
- Ignoring the JavaScript glue. Large glue files cost real time. Enable the compile cache.
- Eagerly touching every export at startup. Defeats lazy compilation.
- Version-specific V8 flags in production. They change between releases. Measure and pin.
- Compiling per worker or per request. Compile once per process and share.
Performance note
For a formatter CLI with a 9 MB module, startup fell from 480 ms to 210 ms without changing the module’s code generation; a daemon mode brought repeat invocations from an editor down to 8 ms each.
Frequently Asked Questions
Can I v8.serialize a WebAssembly.Module?
Not to persist across processes in current Node versions; modules can be shared with workers in the same process.
Does Deno or Bun cache compiled Wasm? Their behaviour differs and changes between versions; measure startup the same way.
Is --liftoff-only safe?
It is a V8 debugging flag; it reduces compile time but slows execution. Measure before using.
Does a smaller module compile faster? Yes — compile time scales roughly with code size.
How do I stop a daemon from leaking state between projects? Reset or re-instantiate the module between unrelated requests, and recycle it when memory passes a threshold.
Related
- Running Wasm off the event loop in Node.js — sharing modules across workers.
- Loading Wasm in Node.js with ES modules — the loading code.
- Reducing Wasm cold-start latency — the browser equivalent.
- Splitting a Wasm module for lazy loading — compiling less.
← Back to Wasm in Node.js, Deno & Bun