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

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.

A WAT module calling an imported JavaScript function with a string The module writes a message into its memory, calls the imported log function with the pointer and length, the JavaScript function reads those bytes from the exported memory and decodes them, and control returns to the module. module (WAT) linear memory JavaScript import message bytes at 1024 (data segment) call $log_str(1024, 11) read bytes 1024..1035 TextDecoder → 'hello world' returns

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.

Two directions of data flow through imports Data flows out of the module when it calls an import with a pointer and length the host reads. Data flows in when it calls an import with a destination pointer and length the host writes into. Both directions use linear memory as the exchange area. module → host (output) module writes bytes into memory calls import(ptr, len) host reads and decodes logging, results, drawing host → module (input) module reserves space calls import(ptr, len) host writes into that range randomness, input data

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.
  • LinkError on instantiation. A field name in the import object does not match the WAT, or a nested env object is missing.
  • Reading memory before the instance exists. Import functions must reach the memory through a variable assigned after instantiation.
  • Unsigned values printed negative. i32 arrives signed. Use >>> 0 to 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.

Cost of calling an imported JavaScript function Nanoseconds per call from a WAT module into JavaScript imports, for a numeric argument, a pointer and length decoded as a short string, and a call that fills 16 bytes of memory with random values. ns per call (warmed up) log_i32 (number) 5 ns fill_random (16 bytes) 90 ns log_str (decode 11 bytes) 120 ns

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.

← Back to WebAssembly Text Format (WAT) Basics