WebAssembly tutorial
By Flavio Copes
Learn WebAssembly step by step by compiling a small module, loading it from JavaScript, and working with exports, imports, and memory.
WebAssembly is easier to understand when you run it.
So let’s create a small module, compile it, load it in a browser, and call it from JavaScript.
The example will stay deliberately small. We are not building one particular application.
We are using a tiny module to learn the parts every WebAssembly program has:
- source code compiled to a
.wasmbinary - exported functions the host can call
- imported functions supplied by the host
- linear memory shared with JavaScript
- a host environment that controls access to the outside world
By the end, you will know what WebAssembly is, how it works with JavaScript, and when it is worth using.
You only need basic JavaScript for this tutorial. If you need a refresher, my free JavaScript course starts from the foundations.
What WebAssembly is
WebAssembly, usually shortened to Wasm, is a portable binary instruction format.
Browsers can compile and execute that format inside a sandbox.
You normally do not write the binary by hand. You write Rust, C, C++, Go, AssemblyScript, or another supported language. A compiler turns that source into a .wasm file.
JavaScript then loads the file and talks to the module.
Rust, C, C++, Go, ...
|
v
compiler
|
v
module.wasm
|
v
browser, Node.js, or another Wasm runtime
WebAssembly is not a programming language that replaces JavaScript.
It is a compilation target and execution format.
JavaScript remains excellent at user interfaces, browser APIs, events, networking, and application glue. Wasm is useful when we want portable compiled code or a fast CPU-heavy core.
The two are designed to work together.
Start with the text format
A .wasm file is binary. Opening it in a text editor does not teach us much.
WebAssembly also has a readable text format called WebAssembly Text, or WAT.
WAT uses .wat files. Tools can convert WAT to Wasm and back again.
You will rarely write a large application in WAT. It is useful for learning, inspecting compiler output, creating tiny tests, and understanding the execution model.
That makes it perfect for this tutorial.
Install the WebAssembly tools
We will use the WebAssembly Binary Toolkit, usually called WABT.
On macOS, install it with Homebrew:
brew install wabt
On Ubuntu or another Debian-based Linux distribution:
sudo apt install wabt
Check the compiler:
wat2wasm --version
WABT includes several small commands. We will use four:
wat2wasmcompiles text into a Wasm binarywasm2watconverts the binary back to readable textwasm-objdumpshows the structure of a binarywasm-validatechecks that a binary is a valid module
Create a directory for the tutorial:
mkdir webassembly-tutorial
cd webassembly-tutorial
Write your first module
Create a file named math.wat:
(module
(func (export "add")
(param $a i32)
(param $b i32)
(result i32)
local.get $a
local.get $b
i32.add
)
)
This module exports one function named add.
The function accepts two i32 values and returns one i32 value. An i32 is a 32-bit integer.
The function body places both parameters on the Wasm value stack. i32.add removes those values, adds them, and places the result on the stack.
The last value becomes the function result.
Wasm is a stack machine. Instructions consume and produce values on an implicit stack.
For our function, the stack changes like this:
[]
[a]
[a, b]
[a + b]
A compiler normally generates these instructions from a higher-level language.
Compile WAT to Wasm
Compile the text file:
wat2wasm math.wat -o math.wasm
You now have two files:
webassembly-tutorial/
├── math.wat
└── math.wasm
Check the binary:
wasm-validate math.wasm
No output means the module is valid.
Inspect its structure:
wasm-objdump -x math.wasm
The output shows a function section and an export named add.
You can also turn the binary back into text:
wasm2wat math.wasm
This round trip is useful when a toolchain gives you a .wasm file and you want to inspect what it contains.
Load the module from JavaScript
Now create index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>WebAssembly tutorial</title>
</head>
<body>
<p id="output">Loading...</p>
<script type="module">
const response = await fetch('./math.wasm')
const bytes = await response.arrayBuffer()
const { instance } = await WebAssembly.instantiate(bytes)
const result = instance.exports.add(20, 22)
document.querySelector('#output').textContent =
`20 + 22 = ${result}`
</script>
</body>
</html>
Do not open the file directly from Finder.
The page uses fetch(), so serve the directory over HTTP:
python3 -m http.server 8000
Open http://localhost:8000 in the browser.
You should see:
20 + 22 = 42
We just completed the basic WebAssembly path:
fetch()downloaded the binary.arrayBuffer()exposed its bytes.WebAssembly.instantiate()compiled and instantiated the module.instance.exports.addgave JavaScript access to the exported function.- JavaScript called that function like any other function.
The Wasm module did the addition. JavaScript loaded it and displayed the result.
Compile while the file downloads
Browsers can compile a Wasm module while it streams over the network.
Replace the first three JavaScript lines with this:
const { instance } = await WebAssembly.instantiateStreaming(
fetch('./math.wasm'),
)
The rest of the code stays the same.
instantiateStreaming() avoids waiting for the complete file before compilation starts.
The server must send .wasm files with the application/wasm content type. If you cannot control the content type, use the arrayBuffer() version instead.
The local Python server normally sends the correct type.
Understand modules and instances
The JavaScript API separates a module from an instance.
A WebAssembly.Module contains compiled code. A WebAssembly.Instance is a live copy of that module with its imports, memory, and state.
WebAssembly.instantiate() does both steps for us and returns the instance.
You can separate them when you want several instances from one compiled module:
const response = await fetch('./math.wasm')
const bytes = await response.arrayBuffer()
const module = await WebAssembly.compile(bytes)
const first = await WebAssembly.instantiate(module)
const second = await WebAssembly.instantiate(module)
console.log(first.exports.add(2, 3)) //5
console.log(second.exports.add(10, 20)) //30
The browser compiles the bytes once. Each call to instantiate() creates another live instance.
Most applications only need one instance. The distinction matters when you build plugin systems, isolated workers, or several copies of a stateful module.
Export memory from Wasm
Numbers cross the JavaScript-Wasm boundary directly.
Strings, arrays, images, and other larger values need memory.
Wasm uses a contiguous byte buffer called linear memory. JavaScript can access the same buffer through typed arrays.
Let’s add memory to our module.
Replace math.wat with this:
(module
(memory (export "memory") 1)
(data (i32.const 0) "Hello from WebAssembly")
(func (export "add")
(param $a i32)
(param $b i32)
(result i32)
local.get $a
local.get $b
i32.add
)
)
The module now exports one page of memory.
One WebAssembly memory page is 64 KiB. Our text begins at byte offset 0.
Compile the module again:
wat2wasm math.wat -o math.wasm
Now read the bytes from JavaScript:
const { instance } = await WebAssembly.instantiateStreaming(
fetch('./math.wasm'),
)
const bytes = new Uint8Array(instance.exports.memory.buffer, 0, 22)
const message = new TextDecoder().decode(bytes)
console.log(message) //Hello from WebAssembly
instance.exports.memory.buffer is an ArrayBuffer.
Uint8Array gives JavaScript a byte-level view. TextDecoder converts those UTF-8 bytes into a JavaScript string.
My ArrayBuffer guide explains this JavaScript binary-data layer in more detail.
The number 22 is the length of the stored message.
Real toolchains generate helpers that pass strings and objects through memory. Underneath those helpers, the same rules apply: one side writes bytes, passes a pointer and length, and the other side reads them.
Memory is where much of the integration work happens.
Import a JavaScript function
Exports let JavaScript call Wasm.
Imports let Wasm call a function supplied by its host.
Add this import near the top of math.wat, after (module:
(import "host" "log" (func $log (param i32)))
Then add an exported function that calls it:
(func (export "run")
i32.const 42
call $log
)
Compile the module again:
wat2wasm math.wat -o math.wasm
The module now requires an import called host.log.
Provide it when you instantiate the module:
const imports = {
host: {
log(value) {
console.log(`Wasm sent ${value}`)
},
},
}
const { instance } = await WebAssembly.instantiateStreaming(
fetch('./math.wasm'),
imports,
)
instance.exports.run()
The console prints:
Wasm sent 42
The two strings in WAT define the shape of the import object:
"host" "log"
| |
v v
imports.host.log
Instantiation fails with a WebAssembly.LinkError when a required import is missing or has the wrong type.
This import model is also the security boundary.
The module cannot call fetch(), read the DOM, open a file, or access a database unless the host provides that capability.
At this point, the complete math.wat file contains:
(module
(import "host" "log" (func $log (param i32)))
(memory (export "memory") 1)
(data (i32.const 0) "Hello from WebAssembly")
(func (export "add")
(param $a i32)
(param $b i32)
(result i32)
local.get $a
local.get $b
i32.add
)
(func (export "run")
i32.const 42
call $log
)
)
This is the final module used by the remaining examples.
Wasm does not access the DOM directly
A browser Wasm module does not know what document, window, or a button is.
JavaScript owns those APIs.
If a module needs to update the page, it calls an imported JavaScript function. Or it returns data and lets JavaScript update the page.
For example:
const imports = {
ui: {
showResult(value) {
document.querySelector('#output').textContent = value
},
},
}
This is a good separation.
Keep browser interactions in JavaScript. Put computation and portable compiled logic in Wasm.
Frameworks and language toolchains hide much of this bridge, but they cannot remove it.
Use WebAssembly from Node.js
WebAssembly is not limited to browsers.
Node.js exposes the same core WebAssembly JavaScript API.
Create run.mjs next to math.wasm:
import { readFile } from 'node:fs/promises'
const bytes = await readFile(new URL('./math.wasm', import.meta.url))
const imports = {
host: {
log(value) {
console.log(`Wasm sent ${value}`)
},
},
}
const { instance } = await WebAssembly.instantiate(bytes, imports)
console.log(instance.exports.add(7, 8)) //15
instance.exports.run()
Run it:
node run.mjs
Node reads the binary from disk instead of fetching it over HTTP.
The module itself does not change. The host around it changes.
WAT is not the normal production workflow
We wrote WAT to see the machinery clearly.
For real projects, use a language and toolchain that targets Wasm.
C and C++ projects commonly use Emscripten. It can compile existing code and generate the JavaScript glue needed for browser APIs.
Rust projects commonly use wasm-bindgen and wasm-pack. They generate bindings for strings, objects, promises, and browser APIs.
AssemblyScript looks similar to TypeScript, but it is a separate language with types and restrictions chosen for WebAssembly compilation.
Go can compile to Wasm too. TinyGo often produces smaller modules for focused Wasm work.
The exact toolchain changes. The module model does not:
- the compiler produces a Wasm module
- the module exports functions, memory, tables, or globals
- the host supplies required imports
- values cross the boundary through supported types or shared memory
Learning that model first makes every toolchain easier to understand.
WebAssembly is not automatically faster
Wasm is compact, predictable, and designed for efficient compilation.
That does not mean every Wasm function beats JavaScript.
Modern JavaScript engines optimize hot code aggressively. A small calculation can be just as fast in JavaScript, and sometimes faster.
Crossing between JavaScript and Wasm also has a cost. Calling a Wasm function once per pixel or once per small object can lose the benefit you expected.
The best pattern is to move a meaningful block of work across the boundary:
JavaScript prepares input
|
v
one Wasm call performs substantial work
|
v
JavaScript receives the result
Do not rewrite code because Wasm sounds fast.
Profile the application first. Find a CPU-heavy section. Measure the JavaScript version, the Wasm version, and the cost of moving data between them.
Where WebAssembly fits
WebAssembly is a strong fit for work such as:
- image, audio, and video processing
- compression and decompression
- parsers and compilers
- cryptographic operations
- physics and simulation engines
- emulators
- database engines
- existing Rust, C, or C++ libraries brought to the web
It is a poor fit for ordinary DOM manipulation, form handling, network orchestration, and small pieces of business logic.
JavaScript already handles those jobs well.
I would start with JavaScript. If profiling found a real CPU bottleneck, I would isolate that work behind a small interface and test a Wasm implementation.
I would also use Wasm when a mature library already exists in another language. Reusing proven code can matter more than raw speed.
The browser sandbox
WebAssembly runs inside the browser’s security model.
The module gets its own linear memory. Memory accesses are bounds-checked. Code cannot jump to arbitrary addresses outside valid Wasm functions.
The module receives capabilities through imports.
This means a Wasm module does not automatically get filesystem, network, camera, microphone, or DOM access.
But sandboxed does not mean trustworthy.
A malicious module can still consume CPU, allocate memory, exploit a bug in an imported host function, or process data incorrectly. Treat third-party Wasm like any other third-party executable dependency.
Validate where it came from, keep its permissions narrow, and update it when security fixes ship.
Wasm outside the browser and WASI
The core WebAssembly specification defines computation. It does not define files, sockets, clocks, or random numbers.
Browsers provide capabilities through JavaScript imports.
Outside the browser, runtimes often use WASI, the WebAssembly System Interface.
WASI defines portable interfaces for system capabilities. A runtime such as Wasmtime can decide which files, network connections, clocks, or other resources a component may use.
WASI 0.3 added native async functions, streams, and futures to the Component Model in 2026.
This does not mean one Wasm binary runs unchanged everywhere.
A browser module, a WASI module, and a Component Model component can expect different imports and interfaces. Check what the target runtime supports before choosing a toolchain target.
The official WASI documentation explains the current releases and runtime support.
WebAssembly today
WebAssembly is an open web standard supported by modern browsers and major server runtimes.
Wasm 3.0 became the live standard in September 2025. It added features including 64-bit address spaces, garbage-collected types, and native exception handling.
Those features make Wasm a better target for more languages and larger programs.
They do not change the foundation we used in this tutorial.
A module still declares imports and exports. The host still instantiates it. JavaScript still communicates through functions and memory.
That small model scales from our add function to design tools, media editors, game engines, database libraries, and server components.
The official WebAssembly site contains the specifications, feature status, and more learning material.
Related posts about js: