Understanding the Canonical ABI

This guide explains what actually happens when a string or a list<u8> crosses a component boundary: who allocates, who copies, what the core function signature really is, and which types are cheap.

The canonical ABI is the specification that makes the component model work. It defines, mechanically, how each WIT type is lowered into the core numeric types on the caller’s side and lifted back into a typed value on the callee’s. Because it is specified rather than invented per toolchain, two components built by different compilers agree — and because it is mechanical, you can reason about its cost precisely.

Lower, cross, lift On the caller's side each typed argument is lowered into core numeric values, allocating in the callee's memory where a list or string needs to be copied. The core function is then called with those numbers, and the callee lifts them back into its own typed representation. caller — typed resize(pixels, dims, filter) lowering: allocate in the callee, copy the bytes, flatten the record via the callee's exported realloc the core call (i32, i32, i32, i32, i32) → i32 ptr, len, w, h, filter tag callee — typed again fn resize(Vec<u8>, …) lifting: build a Vec over the bytes that were copied in for it owns them, and frees them The same numeric boundary as always — the difference is that the lowering is specified, so any two toolchains agree on it.

Prerequisites

  • [ ] A component to inspect, and wasm-tools to look inside it
  • [ ] Familiarity with the raw (ptr, len) convention this formalises
  • [ ] A profiler, if you intend to act on the cost analysis below
  • [ ] Patience for one uncomfortable fact: lists are copied

How each type is lowered

Scalars

u8, s32, f64, bool, char and enum lower to a single core value each. bool becomes an i32 that is 0 or 1; enum becomes an i32 discriminant. There is no allocation and no copy, so a function taking six scalars is exactly as cheap as a core function taking six numbers.

Strings and lists

Both lower to a pointer and a length — and both require the bytes to exist in the callee’s linear memory, which means the caller allocates there. That is what the exported cabi_realloc function is for: the ABI calls it to obtain space, then copies into it.

list of 8 MB   →   realloc(8 MB) in the callee, memcpy 8 MB, pass (ptr, len)

Records and tuples

Flattened into their fields, recursively, up to a limit. A record { width: u32, height: u32 } becomes two i32 parameters — no allocation. Past a threshold of flattened values the ABI switches to passing a pointer to a laid-out struct instead, which reintroduces a copy.

Variants and results

Lowered as a discriminant plus the union of the payloads’ flattened forms. A result<list<u8>, string> therefore costs a tag plus whichever arm is present — and because both arms are indirect, the return value goes through memory.

Resources

Lowered as an i32 handle into a table the owning side maintains. This is the cheap way to keep state across calls: the handle crosses, the data does not.

What each type costs to cross Scalars and enums cost nothing beyond the call itself. Small records flatten into scalars. Strings and lists allocate and copy proportionally to their length. Resources cost a single handle regardless of how much data they represent. u32, bool, enum one core value, no allocation small record flattened into its fields resource handle one i32 — regardless of the data behind it string allocate + copy, proportional to length list<u8> of 8 MB The bottom row is the one to design around: a resource that owns the buffer avoids paying it on every call.

Reading the lowering for a real function

wasm-tools component wit imaging.wasm | grep -A2 'resize:'
wasm-tools print imaging.wasm | grep -A6 'canon lower'

The canon lower and canon lift entries in the printed component are the ABI adapters: they name the core function, the memory, and the realloc used. Reading them once demystifies the whole mechanism — they are exactly the glue you would have written by hand, generated from the type description.

The same feature, designed two ways An interface that takes and returns lists on every call copies the data twice per invocation. One that opens a resource once and then exchanges small records pays the copy a single time, with subsequent calls costing only a handle and a few scalars. process(list<u8>) -> list<u8> allocate + copy in, allocate + copy out every call, proportional to the data open(list<u8>) -> buffer; buffer.step() one copy at open, then handles per-call cost becomes constant Both express the same capability in WIT. The second is the one to reach for whenever the data outlives a single call.

Gotchas

cabi_realloc missing. A component exporting a function that takes a list must export a realloc for the ABI to allocate through. Toolchains do this automatically; a hand-assembled core module does not.

A large list is copied twice. Once in, once out. Returning a modified copy of an 8 MB input costs 16 MB of copying per call. Restructure so the buffer stays on one side.

Strings are UTF-8, and validation is real. A string crossing the boundary is validated; invalid sequences are an error rather than passing through as bytes. Use list<u8> if the data is not text.

Flattening limits surprise you. A record with many fields switches from flattened parameters to an indirect struct, which changes the cost profile without changing the WIT. If a hot function’s cost jumps after adding a field, this is why.

Resource handles cannot outlive their owner. They are indices into a table that goes away with the instance. Treat them like any other borrowed reference.

Performance note

The one number worth memorising: lists and strings cost a memory allocation plus a copy proportional to their length, in each direction they cross. Everything else in the ABI is arithmetic on a handful of scalars.

That asymmetry should drive interface design. A component API built from scalars, small records and resource handles has a boundary cost indistinguishable from a raw core call. The same API expressed as functions taking and returning large lists pays megabytes of memcpy per call. Both are legal, both look similar in WIT, and their performance differs by orders of magnitude — which is why the cheap shapes are worth knowing before the interface is written rather than after.

Estimating the cost of an interface

Because the lowering rules are specified, the cost of a call can be estimated from the WIT alone, before anything is built. The arithmetic is simple enough to do in a review.

Count the scalars — every u32, bool, enum and small record field contributes one core value and no allocation. Count the variable-length values — every string and list<T> contributes one allocation in the callee plus a copy of its full length. Count the returns the same way, since results cross in the other direction with the same rules.

An example, using the resize interface from earlier:

resize(image: list, from: dimensions, to: dimensions, using: filter)
  -> result, string>

  image      1 alloc + copy of n bytes
  from, to   4 scalars, flattened
  using      1 discriminant + 1 payload scalar
  return     1 alloc + copy of m bytes  (or a string on the error path)

  per call:  2 allocations, n + m bytes copied, ~6 scalars

For a 4 MB image that is 8 MB of copying per call, which at typical memory bandwidth is a few milliseconds — significant if the function is called per frame and irrelevant if it is called per upload. The same arithmetic on an interface built from scalars and handles gives zero allocations and a handful of integers, which is indistinguishable from a raw core call.

Doing that count during interface design, rather than after a profiler flags it, is what keeps a component API fast by construction.

What the specification does and does not fix

It is worth being precise about the guarantee. The canonical ABI fixes how values are lowered, which is what lets two toolchains interoperate. It does not fix how much that costs, because the cost depends on the types you chose. Two components with identical functionality and different interfaces will have very different performance, and both will be perfectly conformant.

It also does not remove the need to think about ownership — it relocates it. In a hand-written ABI you decide who frees a buffer; under the canonical ABI the specification decides, and the answer is that the callee owns what was lowered into its memory. That is simpler and less error-prone, but it does mean the copy is mandatory rather than a choice you can optimise away for a particular call.

Where that cost is unacceptable, the escape hatch is the same as everywhere else on this boundary: keep a raw core-module path for the hot data and use the typed interface for everything else. That is not a failure of the model — it is the ordinary trade between a general mechanism and a specialised one, and the fact that both can coexist in one project is what makes the model practical.

Frequently Asked Questions

Can the ABI ever avoid the copy for lists? Not in the current specification for values crossing between components with separate memories — the callee must own the bytes. Keeping data on one side behind a resource is the practical way to avoid it.

Is this the same cost as wasm-bindgen glue? Comparable, and for the same reason: both must encode and copy. The difference is that the canonical ABI is specified, so the cost is predictable across toolchains rather than an implementation detail.

Do I need to know this to use components? Not to make one work. You need it to make one fast, and to understand why a straightforward-looking interface is slower than expected — see component model vs wasm-bindgen.

Is the lowering the same in every direction? The rules are, but the ownership is not: values lowered into a callee become the callee’s to free, whichever side started the call. That symmetry is what makes the specification tractable, and it is also why a function returning a large list is exactly as expensive as one taking a large list.

← Back to Wasm Component Model & WIT Bindings