The JS and WASM boundary: calls, numbers, and shared memory
WebAssembly never runs alone in the browser — JavaScript instantiates it, calls into its exports, and hands it imports to call back out — but the calling convention only lets raw numbers cross that boundary directly, so every string, object, or array has to be encoded into the shared linear memory first and referenced by a pointer and a length.
The JS and WASM boundary: calls, numbers, and shared memory
Every WASM module you'll ever run in a browser is embedded in a JavaScript program. Nothing loads a .wasm file and just runs it standalone — some JS calls WebAssembly.instantiate, gets back a live instance, calls functions on it, and (often) that instance calls back into JS to do things WASM fundamentally cannot do itself, like touch the DOM. The stack machine from lesson 1 computes; it doesn't log to a console, fetch a URL, or draw a pixel. All of that is JavaScript's job, reached through a boundary.
That boundary has a strict, narrow gate. Function calls across it can only pass and return the four numeric types from lesson 1 — i32, i64, f32, f64. Not strings. Not objects. Not arrays. Nothing with structure. If you've ever wondered why libraries like wasm-bindgen exist, or why passing a JavaScript object into a WASM function isn't a thing you just do, this is the entire answer: richer data has to be flattened into bytes, written into the linear memory both sides can see, and referenced by a plain integer pointer. This lesson walks the gate in both directions — instantiation, calls, and the memory trick that makes anything beyond a number possible.
Instantiation: building the instance out of imports and exports
Before any call can happen, JavaScript has to load and instantiate the module. The core API is WebAssembly.instantiate:
const importObject = {
env: {
log_number: (n) => console.log("from wasm:", n),
now: () => Date.now(),
},
};
const { instance } = await WebAssembly.instantiate(wasmBytes, importObject);
// instance.exports now holds every function the module exported
const result = instance.exports.add(2, 3); // a plain number: 5Two things happen here that set up everything else in this lesson.
Imports are the functions JavaScript supplies to the module, because the module declared, at compile time, that it needs them. A WASM module has no ambient ability to log a value, read the clock, allocate memory from the OS, or touch a single DOM node — it is a sandboxed stack machine with no I/O of its own. Anything it needs from the outside world, it declares as an import with a name and a type signature, and the embedder — here, JavaScript — is responsible for handing over something that satisfies that signature. This is also how WASM "calls back into JS": not through some special reverse-call mechanism, but because the module holds an ordinary reference to a JS function it was handed at instantiation time, and calls it exactly like it would call one of its own internal functions.
Exports are the mirror image: functions (and sometimes memory, globals, or tables) the module makes available for JavaScript to call. instance.exports.add looks and behaves like any other JS function — you call it with (), it returns a value — but underneath, calling it triggers the browser's WASM engine to jump into compiled machine code, run the stack-machine instructions from lesson 1, and return control (and a value) to JS when it's done.
What actually crosses: only numbers, by value
Look again at instance.exports.add(2, 3). The 2 and 3 cross the boundary as raw numeric values — copied in, computed on, copied back out as 5. This is cheap because it's exactly what the calling convention is built for: WASM function signatures are typed purely in terms of i32, i64, f32, f64, so passing a number across is no different in cost from a normal function call in either language alone. There's no serialization, no allocation, no indirection — just values moving through registers or the stack, the way a CPU call instruction already works.
The one wrinkle: i64 (64-bit integers) doesn't map onto JavaScript's number type, because number is a float that can only represent integers exactly up to 2^53. So the JS engine represents a WASM i64 as a JavaScript BigInt instead. Every other numeric type — i32, f32, f64 — maps onto an ordinary JS number with no ceremony.
That's the entire list of things that cross directly. No exceptions, no special cases for "simple" objects or "short" strings. If it isn't one of those four numeric types, it doesn't go through the function-call gate at all.
Everything else: encode it, write it, point at it
So how does greet("world") work, if a WASM function can't receive a string? It doesn't — not directly. What actually happens is a small, mechanical dance through linear memory:
// Conceptually what happens to pass a string INTO wasm
const text = "world";
const bytes = new TextEncoder().encode(text); // Uint8Array of UTF-8 bytes
// Ask the module for a place to put them (often an exported allocator)
const ptr = instance.exports.alloc(bytes.length);
// Write the bytes directly into the module's own memory
const memoryView = new Uint8Array(instance.exports.memory.buffer);
memoryView.set(bytes, ptr);
// Now call the real function with two plain integers: where, and how long
instance.exports.greet(ptr, bytes.length);And to get a string back out of WASM, the module hands JavaScript a pointer and a length (as its numeric return values, or via an out-parameter), and JS reads raw bytes out of the same shared buffer and decodes them:
// Conceptually what happens to read a string BACK OUT of wasm
const ptr = instance.exports.get_greeting_ptr();
const len = instance.exports.get_greeting_len();
const bytes = new Uint8Array(instance.exports.memory.buffer, ptr, len);
const text = new TextDecoder().decode(bytes);Notice what didn't change anywhere in this: the function call itself. greet(ptr, len) is still just two integers crossing the boundary — the gate never widened. What changed is that JS and WASM agreed, out of band, that those two integers mean "a UTF-8 string lives at this offset, this many bytes long" inside a buffer they both already have direct access to: instance.exports.memory.buffer is the exact same ArrayBuffer the WASM module reads and writes when it executes its own load and store instructions. There is no copy of memory made to hand to WASM — JS is looking at literally the same bytes.
That said, there is a copy happening — the TextEncoder().encode() step and the memoryView.set() step both copy bytes, once, from a JS-native string representation into the shared buffer. That copy is real, unavoidable work, and it's the crux of the performance story later in this lesson.
The ownership question nobody skips
The moment you're passing pointers around, a question shows up that plain numbers never raised: who allocated this memory, and who is responsible for freeing it? If WASM allocated the buffer (via its own alloc function, backed by whatever allocator the source language's toolchain compiled in — malloc in C, the Rust global allocator, and so on), JavaScript must not forget to call a matching free export when it's done reading, or that memory leaks inside the module's linear memory for the life of the page. If JavaScript writes into memory the module handed it, it has to respect the length the module gave it — writing past the end corrupts whatever the module put right after that buffer, since linear memory has no per-allocation bounds checking of its own.
This bookkeeping — who allocates, who reads, who frees, in what order — is exactly the tedious, error-prone glue that tools like wasm-bindgen exist to generate for you.
Why this shapes how you call across the boundary at all
Every one of these crossings costs something. A plain numeric call is cheap — close to a native function call. A call that carries a string or an object is a numeric call plus an encode, plus a memory write (or read plus decode) on top. Neither cost is enormous in isolation. The problem is what happens when you make the boundary chatty: calling across it in a tight loop, once per array element, once per DOM node, once per character.
This is the seed of the next lesson: WASM's raw compute speed is real, but it's easy to lose all of it — and then some — to a boundary you're crossing too often, or crossing with the wrong kind of data.
Where this goes next
You now have the full picture of how JS and WASM talk: numbers pass directly, everything else passes as bytes through the shared linear memory from lesson 2, and wasm-bindgen automates the marshaling so you don't hand-write it. Why WASM is fast — and when it isn't picks up exactly where the callout above left off: it names the specific properties that make WASM's compute fast, and the specific situations — this boundary chief among them — where those properties stop paying off.
Go deeper
- MDN — Using the WebAssembly JavaScript API — The canonical reference for WebAssembly.instantiate, imports, exports, and the instance object this lesson builds from.
- The wasm-bindgen Guide — Shows the generated glue code directly — the encode/pointer/decode dance this lesson describes conceptually, as real generated JS and Rust.
- Mozilla Hacks — Calls between JavaScript and WebAssembly are finally fast — The engine-level history of why boundary calls used to be slow and what changed — good grounding for the performance claims in this lesson.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- In WebAssembly.instantiate(bytes, importObject), what problem do imports solve — why can't a WASM module just call console.log or fetch directly?
- List the types that can cross a WASM function-call boundary directly, and explain why i64 needs special handling in JavaScript.
- Walk through, step by step, what has to happen to pass the string "hello" from JS into a WASM function that expects a (ptr, len) pair.
- Why is instance.exports.memory.buffer central to passing anything richer than a number — what would break if JS instead kept its own separate copy of the data?
- What is wasm-bindgen actually generating, concretely, and why is it accurate to say it doesn't invent a new transport mechanism?
- Explain the ownership problem that pointers introduce that plain numeric arguments never raised, and why linear memory doesn't protect you from getting it wrong.
- Why does calling a WASM function 10,000 times in a loop, once per array element, cost more than calling it once with the whole array — even though the total amount of WASM computation is the same either way?