Inside a WASM module: sections & the binary format
A .wasm file is a compiled program in a single self-describing binary — the format that WebAssembly runtimes in browsers, Node, Wasmtime and edge platforms execute. Its best trait, for a viewer, is that it is section-first: after an eight-byte preamble the whole file is a flat list of length-prefixed sections, and everything a tool needs to describe the module sits near the front, before the (often huge) embedded data. This is a tour of that layout — the magic, LEB128 encoding, the section list, the code section and how a function body disassembles back to WAT — and how all of it lets you map a multi-gigabyte module from just its section headers, without loading the payload.
Why WebAssembly has a binary format
WebAssembly has two equivalent forms: a text format (WAT) that people read and write as S-expressions, and a compact binary format (.wasm) that runtimes actually load. A compiler — Rust’s, Clang/Emscripten, Go, TinyGo, AssemblyScript, Zig — emits the binary form directly. It is designed to be streamed and validated in a single pass and to mmap cleanly, which is exactly why its structure is so friendly to a viewer: the module announces each section’s length up front, so you can walk it without decoding what you don’t need.
The file layout: a preamble and a list of sections
Every module is an 8-byte preamble followed by any number of sections, each one a (id, byte-length, payload) record:
┌───────────────────────────────────────────────┐ offset 0
│ PREAMBLE \0asm (4 bytes) · version u32 (=1) │
├───────────────────────────────────────────────┤
│ SECTION id u8 · size uleb · payload[size] │ Type
│ SECTION id u8 · size uleb · payload[size] │ Import
│ SECTION id u8 · size uleb · payload[size] │ Function
│ … (Table, Memory, Global, Export …) │
│ SECTION id u8 · size uleb · payload[size] │ Code ← function bodies
│ SECTION id u8 · size uleb · payload[size] │ Data ← static bytes (big)
│ SECTION id 0 · size uleb · "name" … │ Custom (debug names)
└───────────────────────────────────────────────┘
Because each section is prefixed with its byte length, you can read one section’s header, learn how long it is, and jump straight to the next — a seek-walk of the whole file that touches only a few bytes per section. That is the trick that makes a byte map of a huge module cheap.
The preamble: magic and version
The file opens with the four bytes 00 61 73 6d — a NUL followed by ASCII asm, usually written \0asm — then a little-endian uint32 version, which has been 1 since the MVP shipped in 2017.
// "\0asm" read as a little-endian u32 (bytes 00 61 73 6d)
const WASM_MAGIC = 0x6d736100;
function checkPreamble(dv) {
if (dv.getUint32(0, true) !== WASM_MAGIC) throw new Error('not a WASM module');
const version = dv.getUint32(4, true); // 1
return version; // sections start at offset 8
}
LEB128: how every length is encoded
Almost every integer in a module — section sizes, vector counts, indices, the immediates of instructions — is LEB128, a variable-length encoding. Each byte carries 7 bits of value in its low bits and a continuation flag in the top bit: while the top bit is set, keep reading. It keeps small numbers to a single byte, which is most of them.
// Unsigned LEB128 → a Number
function uleb(bytes, pos) {
let result = 0, shift = 0, byte;
do {
byte = bytes[pos++];
result += (byte & 0x7f) * 2 ** shift; // low 7 bits
shift += 7;
} while (byte & 0x80); // top bit = "more to come"
return { value: result, pos };
}
Signed values (the immediates of i32.const and i64.const) use the signed variant, which sign-extends from the last byte’s bit 6. Getting LEB128 right is most of the work of parsing a module; once you have it, the rest is a walk.
The section list: id, size, payload
With the preamble behind you, the file is a loop: read a one-byte id, read a LEB128 size, and you now know the payload spans exactly size bytes. The ids are a small fixed set:
| id | Section | What it holds |
|---|---|---|
| 0 | Custom | Named blobs — debug names, producers metadata |
| 1 | Type | Function signatures (param and result types) |
| 2 | Import | What the module needs from the host |
| 3 | Function | Which type each defined function has |
| 4 / 5 / 6 | Table / Memory / Global | Tables, linear memory, globals |
| 7 | Export | What the module exposes to your code |
| 8 / 9 | Start / Element | An init function; table element segments |
| 10 | Code | The actual function bodies (bytecode) |
| 11 | Data | Bytes to load into memory — often the bulk of the file |
The walk itself is tiny, and because we only need each section’s header to find the next, it never has to load a payload:
function* sections(bytes) {
let pos = 8; // past the preamble
while (pos < bytes.length) {
const id = bytes[pos];
const { value: size, pos: p } = uleb(bytes, pos + 1);
yield { id, offset: pos, size, payloadStart: p };
pos = p + size; // jump over the payload
}
}
A function lives across three sections
WebAssembly deliberately splits a function into three places so the module can be validated in one streaming pass. The Type section lists distinct signatures. The Function section is just a list of type indices — the ith defined function has the ith type here. The Code section holds the matching bodies. And imported functions come first in the function index space, so the first defined function’s index is the number of imported functions:
// funcIndex → which section it lives in
// 0 .. importedFuncs-1 imported (declared in the Import section)
// importedFuncs .. n defined (Function section + Code section)
const funcIndex = importedFuncCount + i; // i = position in the Function section
Get that offset wrong and every call in the disassembly points at the wrong function — it is the single most common bug when reading a module by hand.
Disassembling a function body back to WAT
A Code entry is a byte length, then a small header of local declarations (run-length encoded: “3 × i32, 2 × i64”), then a stream of instructions ending at the end opcode 0x0b. Each instruction is a one-byte opcode, sometimes followed by immediates. The genius of the encoding is that the huge numeric/comparison range (0x45–0xc4: i32.add, f64.mul, every comparison and conversion) takes no immediates — it is pure stack manipulation — so a disassembler is mostly a lookup table:
while (!atEnd) {
const op = next();
switch (op) {
case 0x20: emit(`local.get ${uleb()}`); break; // variable access
case 0x21: emit(`local.set ${uleb()}`); break;
case 0x41: emit(`i32.const ${sleb()}`); break; // signed immediate
case 0x10: emit(`call ${uleb()}`); break; // call by func index
case 0x02: emit('block'); depth++; break; // opens a scope
case 0x0b: depth--; emit('end'); break; // closes one
case 0x6a: emit('i32.add'); break; // no immediate
// …the numeric range is one big immediate-free table…
}
}
The instructions that do carry immediates are a short list: the control ops (block/loop/if take a block type; br/call take an index), the variable and memory ops (a memarg = alignment + offset), and the four consts. Two multi-byte prefixes, 0xfc and 0xfd, introduce the saturating-truncation / bulk-memory and SIMD families. OmniViewer’s disassembler covers all of these; when it meets an opcode it doesn’t know, it stops that one function cleanly and re-syncs at the next body’s declared length — so the output is always finite and the rest of the module still decodes.
bodyStart + bodySize and carry on. One weird function can’t derail the whole decompile.The data section: where the bytes hide
The Data section carries the bytes a module copies into its linear memory at startup — string tables, static arrays, an embedded font, a whole SQLite database, a Python standard library. In a real-world module this section is frequently larger than everything else combined. Each segment is a small descriptor (a mode flag, an offset expression) followed by a length-prefixed blob. To describe a module you need the descriptor and the length; you never need the blob’s contents. OmniViewer reads the descriptor and then seeks past the payload rather than retaining it:
const flag = uleb(); // 0 = active, into memory 0
if (flag === 0) readConstExpr(); // the offset expression
const size = uleb(); // the payload length
cursor += size; // skip it — never load the bytes
Mapping a multi-gigabyte module cheaply
Put the two ideas together — sections announce their length, and data payloads can be skipped — and a huge module becomes cheap to inspect. OmniViewer seek-walks the section table with one small read per section boundary, so the byte map (the Sections tab) is complete even for a 2 GB module whose Data section is 1.9 GB of it. Then it reads a bounded prefix that covers the type, import, function and code sections and decompiles those to WAT — the code of virtually every real module is a few megabytes and sits near the front, before the data. The payload is never pulled into memory, and the whole thing runs off the main thread in a Web Worker:
// 1) byte map: read only each section's header (a few KB total, any size)
for (const s of walkSectionHeaders(file)) table.push(s);
// 2) decompile: read a bounded content prefix, grow only if the code needs it
let prefix = await file.slice(0, 8 * 1024 * 1024).arrayBuffer();
const module = parseWasm(new Uint8Array(prefix), file.size); // WAT + interface
This is the same shape OmniViewer uses for other header-first formats — read the map from the structure, decode only the prefix that matters, and let the gigabytes on disk stay on disk.
Where the spec lives
The authoritative reference is the WebAssembly core specification, binary format — it defines the section ids, the value-type tags, LEB128, and every opcode and its immediates. The WABT toolkit (whose wasm2wat this viewer’s WAT output mirrors) and wasm-tools are the reference implementations. OmniViewer’s reader is a dependency-free JavaScript implementation of the same structure, small enough to run entirely in your browser.