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.
Prerequisites
- [ ] A component to inspect, and
wasm-toolsto 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.
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.
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.
Related
- Wasm component model & WIT bindings — the layer this ABI implements.
- Writing your first WIT interface — the types whose costs are described here.
- Passing complex types across the boundary — the hand-written version of the same problem.
- Zero-copy data transfer patterns — what you give up when the ABI copies.
← Back to Wasm Component Model & WIT Bindings