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 (hyperfine or performance.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.

Startup cost of a Node CLI that loads a large Wasm module Startup is the sum of reading and compiling the JavaScript glue, reading the Wasm file, validating and baseline-compiling it, instantiating and initialising memory, and compiling functions lazily on first calls. Each layer has different remedies. JS glue load + compile Node compile cache helps read .wasm from disk usually small validate + baseline compile lazy compilation spreads it instantiate + init snapshot initialised state first calls lazy tier-up of hot functions

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.

What can and cannot be cached across Node runs Node's compile cache persists compiled JavaScript including Wasm glue, and Wizer can persist initialised Wasm memory into the module. Compiled Wasm machine code is not persisted by Node across processes, though lazy compilation reduces how much is compiled per run. cacheable across runs compiled JS glue (compile cache) initialised state (Wizer snapshot) precomputed data files use them not persisted by Node compiled Wasm machine code tier-up decisions instance memory reduce instead

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.

Formatter CLI startup after each change Milliseconds of startup for a formatter CLI loading a 9 megabyte module, initially, with Node's compile cache, with a Wizer snapshot of initialisation added, and per request in daemon mode. ms per invocation initial 480 ms + compile cache 360 ms + Wizer snapshot 210 ms daemon mode (repeat calls) 8 ms

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.

← Back to Wasm in Node.js, Deno & Bun