Importing JavaScript Functions into WAT
This page answers one task: let a hand-written WebAssembly module call JavaScript — to log a value, read the time, draw something or report a result — by declaring imports in WAT and providing them from the host.
Prerequisites
- [ ] WABT or
wasm-tools, and a browser or Node. - [ ] Familiarity with WAT functions and memory, as in reading and writing memory in WAT.
Imports are the module’s only way out
A WebAssembly module cannot do anything outside its own memory unless the host gives it a function to call. An import declares such a function: a two-part name — module and field — and a type. At instantiation the host supplies a JavaScript function under those names; from then on, the module calls it like any of its own functions. Every observable effect a hand-written module has — printing, timing, drawing — goes through imports, which makes them both the module’s interface and its complete list of capabilities.
Because imports carry only WebAssembly value types — integers, floats, references — anything richer has to be encoded. Numbers pass directly. Strings and arrays pass as a pointer and a length into the module’s linear memory, which the JavaScript side reads. That convention is the core of every toolchain’s generated glue; doing it by hand once makes the glue easy to understand.
Step 1 — declare imports
Imports must appear before any functions the module defines, because imported functions take the lowest function indices:
(module
(import "env" "log_i32" (func $log_i32 (param i32)))
(import "env" "log_str" (func $log_str (param i32 i32))) ;; pointer, length
(import "env" "now_ms" (func $now_ms (result f64)))
(memory (export "memory") 1)
(data (i32.const 1024) "hello world")
(func (export "run")
(call $log_i32 (i32.const 42))
(call $log_str (i32.const 1024) (i32.const 11))
(call $log_i32 (i32.trunc_f64_s (call $now_ms)))))
The names inside quotes — "env" and "log_i32" — are what the host must match. The $log_i32 name is only for use inside the WAT. Any module
name works; env is a common convention.
Step 2 — provide the functions from JavaScript
let memory;
const decoder = new TextDecoder();
const imports = {
env: {
log_i32: (x) => console.log("i32:", x),
log_str: (ptr, len) => console.log("str:", decoder.decode(new Uint8Array(memory.buffer, ptr, len))),
now_ms: () => performance.now(),
},
};
const { instance } = await WebAssembly.instantiateStreaming(fetch("imports.wasm"), imports);
memory = instance.exports.memory;
instance.exports.run();
i32: 42
str: hello world
i32: 1873
log_str reads the memory at call time, through a variable assigned after instantiation — the import object has to exist before the instance,
so the function cannot capture the exports directly. The full set of rules for import objects is in
writing an import object by hand.
Step 3 — pass values back into the module
Imports can return values, which the engine converts to the declared result type. An import that returns data larger than a number writes it into the module’s memory at a location the module provides:
(import "env" "fill_random" (func $fill_random (param i32 i32))) ;; ptr, len
(func (export "random_sum") (result i32)
(local $i i32) (local $acc i32)
(call $fill_random (i32.const 4096) (i32.const 16)) ;; host writes 16 bytes
(block $done
(loop $next
(br_if $done (i32.ge_u (local.get $i) (i32.const 16)))
(local.set $acc (i32.add (local.get $acc) (i32.load8_u offset=4096 (local.get $i))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $next)))
(local.get $acc))
env.fill_random = (ptr, len) => crypto.getRandomValues(new Uint8Array(memory.buffer, ptr, len));
The module chooses where the data goes and how much; the host fills exactly that range. Validating ptr and len in the host function is good
practice — a bug in the module could otherwise make the host write anywhere in memory.
Step 4 — use the right types at the boundary
The engine converts values on every call according to the import’s declared types. Integers arrive in JavaScript as numbers in the signed 32-bit
range: an i32 holding 0xFFFFFFFF arrives as -1; use x >>> 0 in JavaScript to view it as unsigned. Floats pass unchanged. i64 values
arrive as BigInt, and an import returning an i64 must return a BigInt. Returning the wrong JavaScript type from an import does not fail at
instantiation — it is converted when the function returns, which can silently turn a string into 0 for an i32 result. Keep imports small and
typed precisely.
Step 5 — keep the import list intentional
Each import is a capability, so give hand-written modules only the functions they need, and name them for what they do rather than which browser
API implements them. A module that imports env.now_ms can be tested with a fake clock; one that imports a generic “call any JavaScript function”
helper cannot be reasoned about at all. The principle is developed further in
restricting what a module can import.
Designing a small import interface
When you control both sides — a hand-written module and the page that hosts it — the import interface is a design decision, and a little care
makes the module easier to test and reuse. Group imports under a module name that describes their role rather than their implementation:
"console" for logging, "clock" for time, "canvas" for drawing. Prefer a few general functions with clear contracts over many specific ones:
one draw_rect(x, y, w, h, rgba) is easier to maintain than separate functions per colour.
Decide early how errors flow back. An import that can fail — reading from storage, decoding an image — should return a status code the module checks, not throw, because a JavaScript exception unwinds straight through the WebAssembly frames and leaves the module no chance to clean up. And write the JavaScript side as a factory that returns the import object, taking its dependencies as parameters, so tests can pass fakes: a clock that returns fixed times, a canvas stub that records calls. That turns a hand-written module into something that can be tested in Node without a browser, which is the main way small WAT modules stay correct as they grow.
Importing more than functions
Functions are the most common import, but WAT can import every kind of entity, and the others are useful in hand-written modules. A memory import lets JavaScript create the memory and share it. A global import passes configuration — a constant or a mutable value both sides can read and update — without a function call. A table import lets several modules share function references. The syntax mirrors function imports:
(import "env" "memory" (memory 1))
(import "env" "debug_level" (global $debug i32))
(import "env" "scratch" (global $scratch (mut i32)))
(import "env" "callbacks" (table 4 funcref))
Immutable globals can be provided as plain numbers; mutable ones need a WebAssembly.Global created with mutable: true, so both sides see the same
value. Using a global for a debug level or a feature flag is a tidy alternative to an extra function parameter on every export.
Expected output
Calling run() logs three lines — the number, the string decoded from memory, and the current time — and random_sum() returns a value between 0
and 4080 that changes on every call.
Gotchas
- Imports declared after functions. WAT requires imports first. The assembler reports an error otherwise.
LinkErroron instantiation. A field name in the import object does not match the WAT, or a nestedenvobject is missing.- Reading memory before the instance exists. Import functions must reach the memory through a variable assigned after instantiation.
- Unsigned values printed negative.
i32arrives signed. Use>>> 0to display it as unsigned.
Performance note
A call from WebAssembly into a JavaScript import cost about 5 ns in Chrome for numeric arguments once warmed up. The string version cost more
because of TextDecoder and the typed-array view — about 120 ns for an 11-byte string — which is why logging inside hot loops should be kept
out of release builds.
Frequently Asked Questions
Can an import be a method on an object?
Yes, but the engine calls it without a this value. Wrap methods in arrow functions or bind them.
Can an import be async?
It can return a Promise, but the module receives the Promise converted to its result type — usually 0 or NaN. Waiting for async work needs JSPI
or Asyncify; see calling async JavaScript with JSPI.
Can I import a function from another Wasm module? Yes — pass another instance’s export as the import value. Calls between the modules then skip JavaScript conversion.
Do imports work the same in Node?
Yes. In Node, TextDecoder and crypto.getRandomValues are available globally as in browsers.
Related
- Defining functions and locals in WAT — the function syntax the imports are called from.
- Encoding strings across the Wasm boundary — strings in both directions.
- Reading the import and export sections — how imports are encoded.
- Measuring JS-to-Wasm call overhead — the cost of the crossing.
← Back to WebAssembly Text Format (WAT) Basics