Understanding LEB128 Encoding

This guide answers one task: read and decode the LEB128 integers that make up most of a WebAssembly binary — section sizes, indices, immediates and counts — so that a hex dump of a module becomes intelligible.

Prerequisites

  • [ ] A .wasm file and a hex viewer such as xxd.
  • [ ] Comfort with binary and hexadecimal.
  • [ ] wasm-objdump, for checking your decoding against a tool.
  • [ ] No tooling beyond that; this is deliberately a by-hand exercise.

Why a variable-length integer

A WebAssembly module is dense with small numbers: function indices, local counts, section lengths, byte offsets. Encoding each as a fixed four bytes would waste three bytes on nearly all of them.

LEB128 — little-endian base 128 — encodes an integer in as many bytes as it needs. Each byte carries seven bits of the value in its low bits, and its high bit indicates whether another byte follows.

byte:  1xxxxxxx   more bytes follow
byte:  0xxxxxxx   last byte

Values under 128 take one byte, under 16,384 take two, and so on. Since the great majority of numbers in a module are small, the saving across a whole binary is substantial — commonly 20–30% against a fixed-width encoding.

Seven bits per byte, low group first The value's bits are taken seven at a time starting from the least significant. Each byte stores a group in its low seven bits and sets its high bit if another byte follows. value 624485 = 0b10011000011101100101 groups of 7, least significant first 1100101 0001110 0100110 with continuation bits 1 1100101 = 0xE5 1 0001110 = 0x8E 0 0100110 = 0x26 → E5 8E 26 The last byte's high bit is clear, which is how a decoder knows to stop — there is no length prefix anywhere.

Decoding unsigned by hand

The algorithm is short: take each byte’s low seven bits, shift by seven per byte consumed, stop when a byte has its high bit clear.

function decodeULEB128(bytes, offset = 0) {
  let result = 0, shift = 0, i = offset;
  for (;;) {
    const byte = bytes[i++];
    result |= (byte & 0x7f) << shift;
    if ((byte & 0x80) === 0) break;
    shift += 7;
  }
  return { value: result >>> 0, next: i };
}
decodeULEB128(Uint8Array.from([0xe5, 0x8e, 0x26]));
// { value: 624485, next: 3 }

Working it through by hand: 0xE5 is 1110 0101, so the continuation bit is set and the payload is 110 0101 = 101. 0x8E is 1000 1110, continuation set, payload 000 1110 = 14, shifted by 7 gives 1792. 0x26 is 0010 0110, continuation clear, payload 38, shifted by 14 gives 622592. The sum is 624485.

The signed form is different

Signed values use a variant with sign extension, and confusing the two is the most common source of nonsense when hand-decoding. Integer constants — the immediate of i32.const — are signed; indices and lengths are unsigned.

function decodeSLEB128(bytes, offset = 0) {
  let result = 0, shift = 0, i = offset, byte;
  do {
    byte = bytes[i++];
    result |= (byte & 0x7f) << shift;
    shift += 7;
  } while (byte & 0x80);
  if (shift < 32 && (byte & 0x40)) result |= (~0 << shift);   // sign extend
  return { value: result | 0, next: i };
}

The extra step is the sign extension: if the final byte’s second-highest bit is set, the value is negative and the remaining high bits are filled with ones. Decoding 0x7f as unsigned gives 127; as signed it gives −1, and both are correct for their context.

0x7f          unsigned → 127      signed → -1
0xc0 0xbb 0x78  unsigned → 1973696   signed → -123456

Reading it in a real module

Everything in a module’s structure is length-prefixed with these integers, which makes a hex dump navigable once you can decode them.

xxd -l 32 dist/engine.wasm
# 00000000: 0061 736d 0100 0000 0107 0160 027f 7f01  .asm.......`....
# 00000010: 7f03 0201 0007 0a01 0670 726f 6365 7373  .........process

Reading it: 00 61 73 6d is the magic number, 01 00 00 00 the version. Then 01 is the type section’s identifier, 07 is its size in bytes as a LEB128, 01 is the count of types, 60 marks a function type, 02 is the parameter count, 7f 7f are two i32 parameters, 01 is the result count and 7f the result type.

Then 03 is the function section, 02 its size, 01 the count, 00 the type index. Then 07 is the export section, 0a its size, 01 the count, 06 the name’s length, and the six bytes spelling process.

Every one of those small numbers is a LEB128, and almost all of them are one byte because almost all of them are small.

Every structural number is a LEB128 Section identifiers, section sizes, entry counts, name lengths and type indices are all variable-length integers, which is why a module's header is compact and why a parser must decode rather than skip fixed widths. 00 61 73 6d magic, fixed 01 00 00 00 version, fixed 01 section id 07 size (LEB) 01 count (LEB) 60 func type … and so on, all the way down Only the eight-byte header is fixed width. Everything after it is length-prefixed with variable-length integers. Which is why a parser cannot seek — it must decode from the start to know where anything is.

Encoding, for when you write bytes

Writing the encoding is as short as reading it, and is occasionally needed — patching a module, generating one, or building a probe module by hand.

function encodeULEB128(value) {
  const out = [];
  do {
    let byte = value & 0x7f;
    value >>>= 7;
    if (value !== 0) byte |= 0x80;
    out.push(byte);
  } while (value !== 0);
  return out;
}

function encodeSLEB128(value) {
  const out = [];
  for (;;) {
    const byte = value & 0x7f;
    value >>= 7;                                      // arithmetic shift, preserves sign
    const signBit = byte & 0x40;
    if ((value === 0 && !signBit) || (value === -1 && signBit)) { out.push(byte); return out; }
    out.push(byte | 0x80);
  }
}

The signed encoder’s termination condition is the subtle part: it stops when the remaining value is all zeros or all ones and the sign bit of the last emitted group agrees, which is what makes the decoder’s sign extension recover the original value.

One consequence worth knowing: patching a value in place is only safe when the new value encodes to the same number of bytes. Changing a section size from 100 to 200 makes it two bytes instead of one, which shifts everything after it — which is why tools rewrite a module rather than editing it, and why a hand-edited binary so often fails validation in a place unrelated to the edit.

Where parsers go wrong

Three mistakes account for most LEB128 bugs in hand-written parsers.

Not advancing the cursor by the encoded length. The number of bytes consumed varies, so a parser must use the decoder’s returned position rather than assuming one byte. Assuming one works for every small value and fails the first time a section exceeds 127 bytes — which is to say, immediately in any real module.

Using the unsigned decoder for a signed value. An i32.const -1 decodes as 127 rather than −1, and the module appears to contain plausible nonsense.

Ignoring the maximum length. A malicious or corrupt module can encode a value with many redundant continuation bytes, which a naive decoder will happily consume forever. The specification bounds the encoding at five bytes for a 32-bit value and ten for a 64-bit one; a parser reading untrusted input must enforce that.

function decodeULEB128Safe(bytes, offset, maxBytes = 5) {
  let result = 0, shift = 0, i = offset, count = 0;
  for (;;) {
    if (++count > maxBytes) throw new Error('LEB128 too long');
    if (i >= bytes.length) throw new Error('LEB128 truncated');
    const byte = bytes[i++];
    result |= (byte & 0x7f) << shift;
    if ((byte & 0x80) === 0) break;
    shift += 7;
  }
  return { value: result >>> 0, next: i };
}

Expected output

Decoding a module’s header by hand should agree with the tool:

wasm-objdump -h dist/engine.wasm | head -3
#      Type start=0x0000000a end=0x00000011 (size=0x00000007) count: 1
#  Function start=0x00000013 end=0x00000015 (size=0x00000002) count: 1
#    Export start=0x00000017 end=0x00000021 (size=0x0000000a) count: 1

Those sizes — 7, 2, 10 — are exactly the LEB128 values at offsets 9, 18 and 22 in the hex dump. Matching them by hand once is the fastest way to be confident you have understood the encoding, and it makes the binary format legible rather than opaque from then on.

Why small numbers dominate the format The encoding spends one byte per seven bits of value. Almost every integer in a real binary is small, which is why the format uses it everywhere instead of fixed-width fields. 0 to 127 1 byte indices, small sizes, most immediates in practice 128 to 16,383 2 bytes function indices in a medium module up to 2,097,151 3 bytes section lengths and larger offsets a full 32-bit value 5 bytes one byte more than a fixed-width field The worst case costs a byte more than a fixed field; the common case costs three or four fewer. A decoder must stop at the first byte with the high bit clear, and reject an encoding longer than the type.

Gotchas

  • Assuming one byte. Correct until a value exceeds 127, which happens quickly.
  • Signed versus unsigned. Indices are unsigned; const immediates are signed.
  • No length bound. A corrupt input can loop forever in a naive decoder.
  • Shifting past 31 bits in JavaScript. Bitwise operators work on 32-bit integers; a five-byte value needs care, and a 64-bit one needs BigInt.
  • Sign extension skipped. Negative constants decode as large positive numbers.
  • Assuming the encoding is canonical. Redundant trailing zero groups are legal in some contexts, so two encodings can represent the same value.

Performance note

Decoding a LEB128 costs a few nanoseconds and appears in every module parse, so a parser reading a large module performs millions of them. The naive loop above is fast enough for tooling; a decoder in a hot path benefits from the common-case shortcut of checking whether the first byte has its high bit clear and returning immediately, which covers the majority of values in a typical module.

Frequently Asked Questions

Why little-endian base 128 rather than a simpler scheme? It is compact for small values, self-terminating so no length prefix is needed, and simple to decode with a shift and a mask. It also predates WebAssembly — DWARF uses it, which is where the name comes from.

Do I ever need to write this by hand? Rarely, and it is worth being able to. Reading a hex dump, writing a small tool that inspects modules, or debugging a binary a tool has mangled all become straightforward once the encoding is familiar.

Are the encodings always minimal? Not necessarily. The specification allows some non-minimal encodings in certain positions, so two byte sequences can decode to the same value — which matters if you are comparing modules byte for byte.

← Back to Wasm Binary Format Deep Dive