Providing Memory at Instantiation

This page answers one task: instead of letting a module create its own linear memory, create the memory in JavaScript, pass it in as an import, and use the fact that the host owns it — to share it, size it, or put data in it before the module runs.

Prerequisites

  • [ ] A module that imports its memory — linked with --import-memory, as in importing memory from the host with --import-memory, or written in WAT with an (import "env" "memory" (memory …)).
  • [ ] The import’s declared limits, from wasm-tools print --skeleton or wasm-objdump -j Import.

Who owns the memory changes what the host can do

A module that defines its own memory creates it at instantiation and exports it if the host needs access. The host only ever sees the memory after the module exists, through instance.exports.memory. A module that imports its memory receives one the host created earlier. That small change in ownership opens up several things the export pattern cannot do.

The host can share one memory between several instances — a main module and side modules, or one instance per worker in a threaded build. It can prepare the memory before the module runs, writing input data or configuration into a known region. It can size it according to the application rather than the module, choosing initial and maximum pages within the module’s declared limits. And it can keep a reference to the memory that remains valid even if the instance is discarded and recreated, which simplifies hosts that restart modules after failures.

Memory the module creates versus memory the host provides When the module defines and exports its memory, the host sees it only after instantiation and cannot share it. When the host creates the memory and passes it as an import, it can size it, prefill it, and share it between instances and workers. module defines + exports created during instantiation visible via exports.memory one memory per instance simplest; no sharing host provides as import created before the module exists host chooses size within limits shareable, prefillable control and sharing

Step 1 — match the module’s limits

Read what the module requires, then create a memory that satisfies it:

wasm-tools print --skeleton kernel.wasm | grep 'import.*memory'
  (import "env" "memory" (memory (;0;) 16 1024))

The module needs at least 16 pages (1 MiB) and accepts a maximum of at most 1024 pages (64 MiB). Create the memory within those bounds:

const memory = new WebAssembly.Memory({ initial: 32, maximum: 512 });   // 2 MiB now, up to 32 MiB
const { instance } = await WebAssembly.instantiateStreaming(fetch("kernel.wasm"), { env: { memory } });

initial may exceed the module’s minimum. maximum must be present if the module declares one and must not exceed it. A memory that violates either rule fails instantiation with a LinkError naming the memory import, as described in handling CompileError and LinkError.

Step 2 — prefill data the module will read

Because the memory exists before the module, the host can write into it first. The module’s own static data and stack are placed by its linker, typically starting near address 1024, so put host data above the module’s heap start or at an address both sides agree on:

const INPUT_AT = 4 * 65536;                        // agreed offset: 256 KiB, above static data and stack
const input = new Uint8Array(await (await fetch("/samples/input.bin")).arrayBuffer());
new Uint8Array(memory.buffer, INPUT_AT, input.length).set(input);

const { instance } = await WebAssembly.instantiateStreaming(fetch("kernel.wasm"), { env: { memory } });
const checksum = instance.exports.process(INPUT_AT, input.length);

Writing before instantiation has one trap: the module’s active data segments are written during instantiation, after your prefill, and will overwrite anything in their range. Keep prefilled data clear of the module’s data region — check __data_end and __heap_base from the module’s globals — or write after instantiation instead.

Step 3 — share one memory between instances

Several instances can import the same memory. A typical case is a main module and a plugin compiled to cooperate, both reading and writing one buffer:

const memory = new WebAssembly.Memory({ initial: 64, maximum: 1024 });
const main = await WebAssembly.instantiate(mainModule, { env: { memory } });
const plugin = await WebAssembly.instantiate(pluginModule, { env: { memory } });

Sharing memory between independently compiled modules only works if they agree on which regions each uses — each module’s linker assumes it owns the static data region and the heap. Real setups use toolchain support for this (dynamic linking, where side modules are relocated into the main module’s memory) rather than hand-coordinated offsets. Sharing between instances of the same module, on the other hand, is the normal threaded pattern, below.

One host-created memory used by a main module and a worker The main thread creates a shared memory and instantiates its module with it, then posts the memory and the compiled module to a worker, which instantiates its own instance against the same memory. Both see each other's writes. main thread shared memory worker new Memory({ initial, maximum, shared: true }) instantiate(module, { env: { memory } }) postMessage({ module, memory }) instantiate with the same memory writes results visible to main thread

Step 4 — share between workers with a shared memory

For threads, create the memory with shared: true and post it to each worker along with the compiled module. Every worker instantiates its own instance against the same SharedArrayBuffer-backed memory:

const memory = new WebAssembly.Memory({ initial: 256, maximum: 4096, shared: true });
const module = await WebAssembly.compileStreaming(fetch("engine_mt.wasm"));
for (let i = 0; i < 4; i++) {
  const w = new Worker(new URL("./engine.worker.js", import.meta.url), { type: "module" });
  w.postMessage({ module, memory, threadId: i });
}

Shared memories require a maximum, require a cross-origin isolated page, and require a module built for threads — with atomics and passive data segments so data is initialised only once. The pattern is the foundation of building a Wasm thread pool.

Step 5 — keep views valid across growth

Whoever grows the memory — the module through memory.grow, or JavaScript through memory.grow(n) — the ArrayBuffer behind non-shared memory is replaced, and every existing typed-array view becomes detached. Owning the memory makes it tempting to keep long-lived views; resist that, or refresh them:

let u8 = new Uint8Array(memory.buffer);
function bytes() {
  if (u8.buffer !== memory.buffer) u8 = new Uint8Array(memory.buffer);   // refresh after growth
  return u8;
}

Shared memory behaves differently: growth keeps the same SharedArrayBuffer object but its byteLength increases, so views remain attached but may be shorter than the memory. Recreate them to see new pages. The details are in why memory.grow invalidates pointers.

Agreeing on a layout with the module

Once the host writes into memory it does not fully control, the host and the module need an agreement about who uses which addresses, and it pays to make that agreement explicit rather than relying on a constant that happens to work. The cleanest arrangement is to let the module own the layout and tell the host where it may write: export a function that allocates an input buffer of a requested size and returns its address, or export globals holding the boundaries of a region reserved for the host.

const ptr = instance.exports.alloc_input(input.length);     // module decides where
new Uint8Array(memory.buffer, ptr, input.length).set(input);
instance.exports.process(ptr, input.length);

That keeps the module’s allocator in charge of its heap, survives changes to the module’s static data size, and works the same whether the memory was imported or exported. Reserve fixed offsets for the cases that genuinely need them — data written before instantiation, or a shared header between cooperating modules — and document them next to the link flags that create the space, such as --global-base, so a later change to the build cannot silently move the module’s data on top of the host’s.

When to provide memory and when not to

Providing memory is the right default in three situations: threaded builds, where it is required; multi-module designs that genuinely share data; and hosts that need to control memory policy, such as a plugin host enforcing a maximum per plugin regardless of what the plugin’s build declares. Outside those, letting the module define and export its memory is simpler and less error-prone — the module’s toolchain sizes it correctly, and the host cannot accidentally hand it a memory that is too small or overlapping with something else. Choosing the import form “just in case” adds configuration without benefit.

Expected output

WebAssembly.Module.imports(module);        // [{ module: "env", name: "memory", kind: "memory" }]
instance.exports.memory;                   // undefined — imported, not exported (unless re-exported)
memory.buffer.byteLength;                  // 2097152 (32 pages)

Gotchas

  • memory import must be a WebAssembly.Memory object. The value under env.memory is missing or is an ArrayBuffer. Pass the Memory.
  • Maximum missing when the module declares one. Instantiation fails. Always set a maximum no larger than the module’s.
  • Prefilled data overwritten. Data segments are written during instantiation. Place host data above the module’s static region.
  • Shared memory on a non-isolated page. shared: true throws. Check crossOriginIsolated first.

Performance note

Providing memory has no effect on execution speed; loads and stores are compiled the same way. Prefilling input before instantiation saved one copy in a pipeline that previously instantiated, then copied the input in through an export call — about 3 ms for a 24 MB image on a laptop.

Getting a 24 MB input into a module two ways Time to have a 24 MB image available to a module, comparing instantiating first and copying in through an allocator export with writing it into a host-provided memory before instantiation. ms until input is in module memory instantiate, malloc, copy in 7.9 ms prefill host memory, then instantiate 4.8 ms

Frequently Asked Questions

Can a module both import and export its memory? Yes — linking with --import-memory --export-memory re-exports it, which suits glue that expects exports.memory.

Can I give a module a memory larger than it asked for? Yes. Initial size may exceed the module’s minimum; the module sees the extra pages as available heap if its allocator uses memory.size.

Can JavaScript shrink the memory? No. Memories only grow.

Does Node or Deno differ? No; WebAssembly.Memory and imported memories work identically. Server runtimes like wasmtime have equivalent APIs in their embedding libraries.

← Back to Wasm Instantiation Lifecycle