Instantiating Modules That Depend on Each Other
This page answers one task: an application is split into several WebAssembly modules — a math library, a codec that uses it, an application module that uses both — and each imports functions or memory that another provides. You want to instantiate them in the right order, wire exports to imports correctly, and handle the cases where modules need each other.
Prerequisites
- [ ] Two or more compiled modules whose imports refer to each other’s exports.
- [ ] The import and export names and types of each (
WebAssembly.Module.importsand.exports, orwasm-tools print). - [ ] Familiarity with
WebAssembly.instantiateand import objects.
Linking at instantiation time
WebAssembly has no built-in module system that resolves dependencies automatically in the core JavaScript API. A module declares imports by module name and
field name ("math" "dot"), and the code that instantiates it supplies an import object providing a value for each. When that value is another instance’s export
— a function, memory, table or global — the two instances are linked: calls go directly from one to the other (engines optimise Wasm-to-Wasm calls through
imports), and shared memories or tables are the same objects in both.
That makes JavaScript the linker. It must know the dependency graph, instantiate modules in dependency order (a module’s imports must exist before it is instantiated), and pass the right exports. For a handful of modules this is a few lines of code; for many, a small loader that reads each module’s import list and resolves it against already-instantiated modules keeps it manageable.
Step 1 — inspect imports and exports
const [mathMod, codecMod, appMod] = await Promise.all(
["math.wasm", "codec.wasm", "app.wasm"].map((u) => WebAssembly.compileStreaming(fetch(u))));
console.log(WebAssembly.Module.imports(codecMod));
// [{ module: "math", name: "dot", kind: "function" }, { module: "env", name: "memory", kind: "memory" }]
console.log(WebAssembly.Module.exports(mathMod));
// [{ name: "dot", kind: "function" }, ...]
Compiling all modules first, in parallel, separates network and compile time from linking. The import and export descriptors tell you what to wire; with the type reflection proposal (where supported), descriptors also include types.
Step 2 — instantiate in dependency order
const memory = new WebAssembly.Memory({ initial: 32, maximum: 1024 });
const math = await WebAssembly.instantiate(mathMod, { env: { memory } });
const codec = await WebAssembly.instantiate(codecMod, { env: { memory }, math: { dot: math.exports.dot } });
const app = await WebAssembly.instantiate(appMod, {
env: { memory },
math: math.exports, // pass the whole exports object when names match
codec: { encode: codec.exports.encode, decode: codec.exports.decode },
});
Passing an exports object directly works when the importing module’s field names match the exporting module’s export names. Missing or mistyped imports produce a
LinkError naming the import, which is the quickest way to find wiring mistakes.
Step 3 — share memory deliberately
Modules compiled from C or Rust each assume they own their memory layout. Sharing one memory between them works only if their static data, stacks and heaps do not overlap and one allocator manages the heap, as described in sharing one memory between two modules. If modules only exchange scalars or copy data through their own exported allocators, give each its own memory — simpler and safer.
Step 4 — handle cyclic dependencies
If module A imports from B and B imports from A, neither can be instantiated first. Break the cycle with indirection: give one module JavaScript stubs that forward calls to the other module once it exists.
let bExports;
const a = await WebAssembly.instantiate(aMod, { b: { helper: (...args) => bExports.helper(...args) } });
const b = await WebAssembly.instantiate(bMod, { a: a.exports });
bExports = b.exports;
The stub adds a JavaScript hop to each call from A to B. Alternatively, share a table: A calls B’s functions through a table slot that is filled after B is instantiated, avoiding JavaScript in the call path. Cycles are usually a design smell; consider merging the modules or moving shared code into a third module both depend on.
Step 5 — automate wiring for many modules
For more than a few modules, write a loader: describe each module’s dependencies (or read them from import descriptors), topologically sort, and instantiate in order, building each import object from previously instantiated modules’ exports plus host-provided imports. Detect missing providers and cycles with clear errors.
When to use something else
Manual linking suits a small number of modules with simple interfaces. For C/C++ code that expects shared libraries, Emscripten’s dynamic linking handles symbol
resolution, memory layout and tables automatically. For modules in different languages exchanging structured data, the Component Model’s composition (wac,
wasm-tools compose) links components with typed interfaces and no shared memory. The ESM integration proposal, where available through bundlers, lets
import statements express Wasm dependencies directly.
A minimal dependency-ordered loader
A loader needs only three pieces of information per module: its compiled WebAssembly.Module, the import module names it expects from other Wasm modules, and
any host imports it needs from JavaScript. With that, a depth-first walk instantiates each module after its providers:
async function link(defs, host) {
const done = new Map(); // name -> instance
const visiting = new Set();
async function load(name) {
if (done.has(name)) return done.get(name);
if (visiting.has(name)) throw new Error(`cycle through ${name}`);
visiting.add(name);
const def = defs[name];
const imports = { ...host };
for (const dep of def.deps) imports[dep] = (await load(dep)).exports;
const instance = await WebAssembly.instantiate(def.module, imports);
visiting.delete(name);
done.set(name, instance);
return instance;
}
for (const name of Object.keys(defs)) await load(name);
return done;
}
Each module is instantiated exactly once, cycles produce a readable error instead of a hang, and adding a new module means adding one entry to defs. Deriving
deps from WebAssembly.Module.imports (every import module name that matches another entry’s key) removes the last piece of hand-maintained configuration.
Sharing a table instead of a memory
When modules exchange callbacks rather than data, a shared WebAssembly.Table of function references is the natural link. The provider exports its functions;
JavaScript places them into a table that the consumer imports, and the consumer calls them with call_indirect. Because table slots can be filled after the
consumer is instantiated, a shared table also breaks cycles without a JavaScript hop in the call path. Allocate slot ranges per module so they do not overwrite
each other, exactly as you would partition a shared memory.
Debugging wiring problems
Most failures show up at instantiation as a LinkError, and the message names the import: Import #2 "codec" "encode": function import requires a callable
means the import object had no encode field, while imported function does not match the expected type means a signature mismatch between two modules built
with different settings. Log WebAssembly.Module.imports of the failing module next to the exports you passed; the mismatch is almost always visible at once.
Versioning linked modules together
Modules linked at instantiation must agree on every shared signature and, if they share memory, on its layout. Ship them as a unit: build them in one pipeline,
give the set one version, and fetch every file from the same versioned path so a cached old codec.wasm is never linked against a new app.wasm. Content-hashed
filenames referenced from one manifest make this automatic. A cheap runtime guard helps too — have each module export a small abi_version global and check that
all values match before running anything.
Expected output
Three modules compile in parallel, instantiate in dependency order with a shared memory where intended, call each other directly through imports, report a
LinkError naming any missing import, and a cyclic pair is untangled by moving shared functions into a third module.
Gotchas
- Instantiating in the wrong order. Imports do not exist yet. Sort by dependencies.
- Sharing memory without layout planning. Modules overwrite each other. Plan or separate.
- Name mismatches between exports and imports. LinkError. Map names explicitly.
- Cycles. Neither module can go first. Use stubs, tables, or refactor.
- Recompiling for each instantiation. Compile once, instantiate many.
- Hand-maintained dependency lists drifting from the binaries. Derive dependencies from
WebAssembly.Module.imports.
Performance note
Calls from one instance to another through a directly passed export cost about the same as an intra-module call in current engines; routing the same call through a JavaScript stub (for cycles) cost about 5–10× more.
Frequently Asked Questions
Can one instance’s memory be imported by another?
Yes — pass the exported WebAssembly.Memory object as the import.
Do imports need exact types? Yes — function signatures, memory limits and global types must be compatible, or linking fails.
Does the order of compilation matter? No — only instantiation order; compile everything in parallel.
Can workers link modules the same way? Yes — post compiled modules to the worker and link there.
Can I instantiate independent modules in parallel?
Yes — modules without dependencies between them can be instantiated concurrently with Promise.all; only dependent ones must wait.
What if two modules export the same name? Nothing conflicts — each import object entry names its providing module explicitly.
Should linked modules be cached separately? Yes, but reference them from one manifest so a deployment always loads a matching set.
Related
- Writing an import object by hand — import objects.
- Handling CompileError and LinkError — linking failures.
- Passing a table at instantiation — shared tables.
- Composing two Wasm components — typed composition.
← Back to Wasm Instantiation Lifecycle