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.imports and .exports, or wasm-tools print).
  • [ ] Familiarity with WebAssembly.instantiate and 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.

Instantiating three modules in dependency order The math module has no Wasm imports and is instantiated first. The codec module imports math's dot function and is instantiated with it. The app module imports functions from both math and codec and is instantiated last. Calls between them go directly from instance to instance. compile all modules in parallel instantiate math no Wasm deps instantiate codec imports math.dot instantiate app imports math + codec direct calls between instances no JS in between

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.

Shared memory versus separate memories between linked modules Sharing one memory lets linked modules pass pointers and avoid copies, but requires non-overlapping layouts and a single allocator. Separate memories keep modules independent and safe from each other's bugs, at the cost of copying data through exported functions when it must cross. one shared memory pass pointers, no copies layouts must not overlap one allocator for all tightly coupled modules separate memories independent layouts bugs stay contained copy data across default

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.

Cost of a call between linked instances Approximate nanoseconds per call from one instance to a function of another instance when the export is passed directly as an import and when the call goes through a JavaScript stub. ns per call (approximate) direct export as import 2 ns through JS stub 15 ns

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.

← Back to Wasm Instantiation Lifecycle