Patrick Burris

Software Developer

WebAssembly From Scratch - Part 1

What to expect

This post sets the foundation for the rest of the series. Before getting into functions, memory, or anything interesting inside WebAssembly itself, I want a development environment that makes it easy to write a small WAT file, compile it, refresh the browser, and immediately see the result. This article assumes you already have your development environment set up with NodeJS.

I will set up a simple Vite project using TypeScript as the host language. Vite gives me a fast dev server and a clean build pipeline without forcing any particular structure on the project. Alongside that I will install WABT so I can compile .wat files to .wasm by hand. Even though toolchains exist that hide this step, I am deliberately not using them. Being able to look at a WAT file and know exactly how it becomes a WASM module is part of the learning process in this series.

Once the structure is in place, I will write a very small WAT module with a function that just adds two numbers. Nothing fancy, just enough to verify that the entire path from WAT to WASM to JavaScript is behaving correctly.

The goal of this post is not to teach the instruction set or dive into memory yet. Instead, the goal is to create an environment that feels frictionless so the later parts of the series can focus entirely on WebAssembly itself. Starting with something this small also gives us a reference point.

What is WASM?

WebAssembly (WASM) is a low-level binary format that runs inside a sandboxed virtual machine in the browser. It was designed to be small and portable. It operates on a linear block of memory and has no hidden state or default garbage collector or much of what makes JavaScript so comfortable. That means that everything we need, we have to build. On the scale of high-level/low-level it is lower than C because the WASM binary is executed directly (where C is compiled into a binary first), but I would also argue that it is higher-level that C because WASM only operates on the WebAssembly virtual machine and C operates at the operating system level.

A WebAssembly module contains functions, globals, an optional memory, and an optional table for function references. Each function uses a stack-based execution model, so instructions push and pop values as they run. This design keeps the format compact and makes it easy for browsers to validate and compile quickly.

WASM on its own cannot do anything interesting. It has no access to the DOM, no timers, no I/O, no canvas, and no global objects. All interaction with the outside world comes from functions that the host environment provides. In the browser that means JavaScript. This separation is intentional. It keeps the WASM side focused on computation and keeps all side effects in the host.

For this series, that split is important. The goal is to write small, focused modules in WAT and to use TypeScript to provide the environment they run in. The more comfortable you are with the core rules of WASM, the easier it becomes to reason about memory layout, function signatures, and how data moves in and out of the module.

WebAssembly Spec WebAssembly - MDN WebAssembly concepts - MDN

What is WASM text?

WebAssembly text, or WAT, is the human-readable form of a WebAssembly module. The binary format is compact and efficient, but not something you would write or inspect directly. WAT exists so you can describe a module in a structured, readable way and then compile it into the final .wasm file that runs in the browser.

A simple example is a good way to see how it works. A function that adds two i32 values looks like this:

(module
  (func (export "add32") (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add
  )
)

Each part is explicit. The module contains one function. The function has two parameters and a return type. The body of the function pulls its arguments from the local stack and performs an addition. The text is a direct description of what the VM will execute.

In this series I will write all modules in the text format first, compile them using wat2wasm, and then load the resulting .wasm files from the browser. This keeps the workflow simple, and more importantly, it keeps the mechanics of WebAssembly visible rather than abstracted away.

Understanding WebAssembly text format - MDN

Installing WABT

To work with WAT files you need a way to compile them into .wasm binaries. WABT (WebAssembly Binary Toolkit) provides the tools for that, including wat2wasm and wasm2wat. The toolkit is small, easy to install, and works the same across all platforms. You only need the wat2wasm tool for this series, but the rest of the utilities are helpful when inspecting or debugging modules.

Below are installation instructions for Windows, Linux, and macOS.

Windows

The easiest way to install it on Windows is to download the prebuilt binaries from the WABT GitHub releases page, here: WABT releases

Download the archive for Windows, extract it somewhere convenient, and add the extracted folder to your PATH. You should then be able to run wat2wasm from PowerShell or Command Prompt.

I am not really a Windows user, so this was my ham-fisted approach.

Linux

Most distributions do not ship WABT as a package, so the common approach is to build it from source. This is straightforward if you have CMake already installed.

git clone https://github.com/WebAssembly/wabt
cd wabt
cmake -S . -B build
cmake --build build --config Release

The compiled binaries will be in build/. To make them available system-wide you can either add that directory to your PATH or move the binaries somewhere under /usr/local/bin.

Some distributions have packages available in their repositories, but they may be out of date. If you prefer the packaged version, check your distro’s package manager:

sudo apt install wabt        # Debian/Ubuntu (sometimes available)
sudo pacman -S wabt          # Arch Linux (available in community)

MacOS

On macOS the easiest way to install WABT is through Homebrew:

brew install wabt

You can also install from source (see the Linux section above).

Verify the install

No matter which platform you are on, you can verify the installation by running:

wat2wasm --version

If this prints a version number, you are ready to compile WAT files.

Creating the project

The goal here is to set up a minimal project that can load a .wasm file produced from a .wat file.

Start by creating a new Vite project with TypeScript:

npm create vite@latest wasm-learning

After the project is created, remove the extra starter files so we are only left with a single entry point. Keeping the structure small at the beginning makes it easier to see where each piece fits. Delete the demo HTML, CSS, and anything else Vite generates by default, but keep main.ts.

Next, create a place for the WAT source files:

src/
  wasm/
    test.wat

For now, test.wat only needs to contain an empty module:

(module)

This is a valid WebAssembly module even though it does nothing.

We also want a place to put the compiled .wasm files so the browser can load them. The simplest option is to put them under public/wasm. That folder is served as-is by Vite.

Now add a script to package.json so you can compile the WAT file into a WASM file. The command will run wat2wasm and write the binary output:

{
  "scripts": {
    "build:wasm": "wat2wasm -o public/wasm/test.wasm src/wasm/test.wat"
  }
}

Create the public/wasm folder and then run the command npm run build:wasm to build the wasm binary.

This is the minimal build step for the series. You update the WAT file, run this script, and the .wasm file becomes available to the browser.

Finally, edit main.ts so the project actually loads the module. This verifies that Vite is serving the file correctly and that the WASM engine is working.

async function main() {
  const wasm = await WebAssembly.instantiateStreaming(
    fetch("wasm/test.wasm")
  );
  console.log(wasm);
}
main();

If you start the dev server now and open the browser console, you should see the empty module logged. There is no exported function yet, but the important part is that the path from .wat -> .wasm -> browser is working.

Getting Started - Vite

A simple end-to-end demonstration

A module is the top-level container for all of the code and data that make up a WASM program. It can contain functions, globals, memory definitions, tables, imports, exports, and data segments. When the browser instantiates a module, it validates the structure, allocates the memory and table if they exist, wires up the imports, and then gives you access to whatever the module exports.

In the text format a module is written as:

(module)

This is a complete, valid module that simply defines nothing. As we add functions or other items they all appear as children of this root (module...) block.

WAT uses s-expressions to describe everything. It is based on Lisp's s-expressions, but there is no evaluation or macro system. The parentheses are just a structured way of describing the contents of the binary format. Each expression corresponds directly to an entry or instruction in the final WASM module, so when you read WAT you are reading a direct representation of what the VM will run.

Now we can write our first function. Below is a complete WAT module with a single exported function:

(module
  (func $add32 (export "add32") (param $a i32) (param $b i32) (result i32)
    (return (i32.add (local.get $a) (local.get $b)))
  )
)

There is a lot going on in that one function, so it is worth breaking it down.

The function form

A function starts with:

(func ...)

This s-expression declares the function, its parameters, its return type, and its body. Inside the parentheses you can declare a local name if you want. In this case $add32 is the internal name. Internal names exist only in the text format and make the code easier to read. They do not survive into the binary. Everything inside a WASM module is referenced by index, not by name.

Export name versus internal name

The (export "add32") annotation gives the function its external name. This is the name that JavaScript will see when the module is instantiated. The browser never sees $add32, only "add32". You can give the internal and external names the same value if you want, but they serve different purposes. The internal name is for you, the WAT author; the export name is for the host environment.

Parameters and types

Each parameter is written as:

(param $a i32)

The

$a

is an optional internal name for readability. The actual type is i32, one of the four numeric types in WebAssembly:

There are no other primitive types in core WASM. If you want something more complex (arrays, structs, strings) you build it manually in linear memory. For now, sticking with i32 keeps things simple.

The result type

A function can return zero or one values. Here we return an i32:

(result i32)

If a function does not return a value, you simply omit the result clause.

The return form

The function body uses:

(return (i32.add ...))

This explicitly returns the result of the addition. In many cases you do not need (return ...) at all. If the last instruction in the function leaves a value on the stack and the function expects a return value, that value becomes the return value automatically. Using return here just makes the example a little clearer for a first pass.

Local variables and local access

Parameters and locals are accessed with local.get and local.set. Here:

(local.get $a)
(local.get $b)

These push the values of $a and $b onto the operand stack. WASM is a stack machine, so instructions operate on values already on the stack. Once both values are present, we can apply the instruction.

Binary operations

i32.add

This pops two i32 values from the stack, adds them, and pushes the result. Every arithmetic operation in WASM works this way. It is always explicit and always type-specific. There is no automatic promotion or conversion.

Putting all of these pieces together, we get a complete function that adds two integers. It is small, but it exercises most of the basic elements you need to understand how WAT describes computation.

In the browser this will compile into a simple WASM module with one export. From TypeScript we can instantiate it and call add32(5, 4) to verify that the module, its export, and the host integration are all behaving correctly. This confirms the full path from text -> binary -> JS -> execution, which is the main goal of this first demonstration.

Running the WASM function

Now that the module contains a real function, the only step left is to load it in the browser and call it. This happens entirely in TypeScript and uses the WebAssembly JavaScript API, which is small and direct. The code below loads the .wasm file, instantiates the module, pulls out the exported function, and runs it.

type TestModule = {
  add32: (a: number, b: number) => number;
};

async function main() {
  const { instance } = await WebAssembly.instantiateStreaming(
    fetch("wasm/test.wasm")
  );

  const testModule = instance.exports as TestModule;
  console.log(testModule.add32(11, 73));
}
main();

There are a few pieces here that are worth understanding clearly.

instantiateStreaming

WebAssembly.instantiateStreaming takes a Response object (in this case from fetch) and begins compiling the module while the bytes are still downloading. Browsers treat WASM differently from JavaScript: they validate and compile the binary as it streams in, which makes startup faster. If the server serves the file with the correct MIME type (application/wasm), streaming compilation works automatically.

If the MIME type is incorrect, or if you are loading from a file system where the server is not doing MIME detection, you can fall back to the non-streaming version:

const bytes = await fetch("wasm/test.wasm").then(r => r.arrayBuffer());
const { instance } = await WebAssembly.instantiate(bytes);

For this project, instantiateStreaming is fine as long as the dev server handles the .wasm type properly, which Vite does.

What instantiateStreaming returns

Both instantiate and instantiateStreaming return an object with two fields:

{
  module: WebAssembly.Module,
  instance: WebAssembly.Instance
}

You almost never need the module field for basic usage. The important part is the instance.

Instance and exports

A WebAssembly.Instance represents a fully initialized module. During instantiation the browser validates the module, allocates linear memory and tables if they were declared, resolves imports, and prepares all exported functions. Once instantiation succeeds, the instance holds everything needed to run the code.

Every exported value from the module is available under instance.exports. This includes:

In our case the module exports a single function called "add32", so the TypeScript code casts instance.exports to the TestModule type and calls it like a normal function. WebAssembly functions imported into JavaScript behave like regular JS functions with simple number parameters and return values.

S-expression Wikipedia WebAssembly.Module - MDN WebAssembly.Instance - MDN

Conclusion

You can write a .wat file, compile it to .wasm, load it in the browser, and call the exported functions from TypeScript. The important part is that the pieces are now connected: the text format, the binary format, the browser’s WASM engine, and the TypeScript host. With this in place we have a solid baseline for exploring how WebAssembly actually works.

Next part

In the next part I will go deeper into the WebAssembly text format itself. Functions, parameters, locals, control flow, globals, data segments, and the basic instruction set will all be covered.