Composing Two Wasm Components
This guide links two components together ahead of time: one that exports an interface, one that imports it, and a single composite artifact that has the dependency satisfied internally and needs nothing from the host to connect them.
Composition is the capability that most distinguishes the component model from every other way of combining WebAssembly. Two core modules can only be connected by a host wiring one’s exports into the other’s import object at run time; two components with matching interfaces can be linked into one artifact by a tool, before deployment, with the types checked at link time.
Prerequisites
- [ ]
wasm-toolswith thecomposesubcommand, orwacfor more complex graphs - [ ] Two components whose WIT package identifiers and versions match exactly
- [ ] A host that can run the composite —
wasmtime, orjcofor JavaScript - [ ] The interface definition available to both builds, ideally as a shared WIT package
Step-by-step procedure
1. Define the shared interface once
// wit/codec.wit — depended on by both components
package example:codec@0.1.0;
interface decode {
decode: func(input: list) -> result, string>;
}
Both sides must reference the same package name and version. This is the part that fails most often, and it fails at compose time with a clear message rather than silently.
2. Build the provider
cd codec && cargo component build --release
wasm-tools component wit target/wasm32-wasip1/release/codec.wasm | head
3. Build the consumer
// app/src/lib.rs
use crate::bindings::example::codec::decode::decode;
impl Guest for Component {
fn run(payload: Vec<u8>) -> Result<u32, String> {
let decoded = decode(&payload)?;
Ok(decoded.len() as u32)
}
}
4. Compose
wasm-tools compose app.wasm -d codec.wasm -o composed.wasm
5. Confirm the import is gone
wasm-tools component wit composed.wasm
# world root {
# export app:main/run@0.1.0;
# }
An import list that still mentions example:codec means the composition did not match — usually a
version mismatch.
6. Run it
wasmtime run --invoke 'run' composed.wasm
npx jco transpile composed.wasm -o dist/ # or for JavaScript
Expected output
$ wasm-tools compose app.wasm -d codec.wasm -o composed.wasm
composed 2 components
$ ls -l app.wasm codec.wasm composed.wasm
-rw-r--r-- 1 dev dev 84210 app.wasm
-rw-r--r-- 1 dev dev 141880 codec.wasm
-rw-r--r-- 1 dev dev 224918 composed.wasm
$ wasm-tools component wit composed.wasm | grep -c import
0
The composite is roughly the sum of its parts — composition links, it does not deduplicate shared code between components, because each keeps its own memory and its own copy of whatever it was built with. That is the cost of the isolation it provides.
Gotchas
no matching export found. The interface identifiers differ — different package name, different
version, or a typo. Print both worlds with wasm-tools component wit and compare the strings.
Composition succeeds but the import remains. More than one import needed satisfying and only one was
provided. Pass a -d for each dependency.
The composite is unexpectedly large. Composition does not share code between components. Two components built from overlapping dependencies each carry their own copy — that is the price of isolation.
A resource handle from one component is passed to another. Handles are owned by the component that created them and cannot be interpreted elsewhere. Interfaces that need to share stateful objects must be designed for it explicitly.
Version bumps break the composition. The version is part of the interface identity, so bumping the provider’s package without updating the consumer’s dependency stops them matching. Treat it as an ABI change, because it is one.
Performance note
A call across a composed boundary costs the canonical ABI’s lifting and lowering, exactly as it would if
the host were mediating. Composition removes the host from the picture and the indirection through
JavaScript that would otherwise come with it, but it does not make the boundary free — two components
have two memories, and a list<u8> crossing between them is copied.
The design consequence is the same as everywhere else on this boundary: put the split where the data flow is naturally narrow. A composition where one component hands another a small descriptor and gets a small result back costs almost nothing; one where megabytes cross on every call pays for it repeatedly, and would be better as a single component.
Where to put the seam
Composition makes it easy to split a system into components, which makes it worth thinking about where the split should go. Two components have separate memories and pay the canonical ABI on every call between them, so the seam should fall where the data flow is narrow and the responsibility boundary is real.
Good seams share a shape: a small amount of data crosses, and the two sides change for different reasons. A codec and the application that uses it qualify — the application hands over a buffer occasionally and the codec’s internals evolve independently. A policy engine and the service consulting it qualify. A rendering component and its own geometry helper do not: they exchange large amounts of data constantly and change together, so splitting them buys isolation nobody needed and costs copies on every call.
The test worth applying is whether you would sensibly version the two halves separately. If the answer is no, they are one component that happens to have two modules inside it, and keeping them together avoids a boundary with no purpose.
Composition in a build pipeline
Composing is a build step like any other, and the artifacts it consumes should be built by the same pipeline rather than fetched by hand:
#!/usr/bin/env bash
set -euo pipefail
(cd codec && cargo component build --release)
(cd app && cargo component build --release)
wasm-tools compose \
app/target/wasm32-wasip1/release/app.wasm \
-d codec/target/wasm32-wasip1/release/codec.wasm \
-o dist/composed.wasm
wasm-tools validate dist/composed.wasm
wasm-tools component wit dist/composed.wasm | grep -q 'import' \
&& { echo "unsatisfied imports remain"; exit 1; } || echo "fully composed"
The last two lines are the useful part. Validating catches a malformed composite, and asserting that no imports remain catches the common failure where the version did not match and the tool silently left the import in place. Both are one line and turn a class of confusing runtime failure into a build failure.
For a larger graph, wac accepts an explicit wiring description rather than matching automatically,
which is worth the extra file once more than two components are involved — implicit matching becomes
ambiguous quickly, and an explicit graph is also documentation of how the system fits together.
Frequently Asked Questions
Can I compose more than two?
Yes. wasm-tools compose accepts several dependencies, and wac handles larger graphs with explicit
wiring when the automatic matching is ambiguous.
Do composed components share memory? No, and that is the point. Each keeps its own linear memory, so a bug in one cannot corrupt the other’s data — the isolation is the same as between a component and its host.
Is this how plugins should work? Only if the plugin set is fixed at build time. For plugins chosen at run time, keep the components separate and let the host supply the implementation — that is the case where late binding is the whole feature.
Does composition affect binary size? Yes, and not favourably: the composite is roughly the sum of its parts, because components do not share code. Two components built from overlapping dependency trees each carry their own copy. That is the price of the isolation — separate memories mean separate everything — and it is a reason to compose at genuine responsibility boundaries rather than splitting finely.
Can a composed component still import from the host? Yes. Composition satisfies the imports you supply components for; anything left unsatisfied remains an import of the composite, which the host provides at instantiation. That is how a composite that needs WASI still works: the codec’s imports are satisfied internally, and the WASI ones are left for the runtime.
What happens to duplicate interfaces during composition? If two components import the same interface and one of them exports it, composition satisfies both from that single export. If neither exports it, it remains an import of the composite for the host to supply. That behaviour is what makes composition useful for building a stack: a low-level component’s export can satisfy several higher-level components at once without any of them knowing about the others.
Can I inspect what a composite contains?
Yes — wasm-tools component wit prints its world, and the printed form shows the inner components as
well. That is worth doing after any composition step, because it is the definitive answer to whether
the wiring you intended is the wiring you got.
Related
- Wasm component model & WIT bindings — the model that makes this possible.
- Writing your first WIT interface — defining the interface both sides share.
- Understanding the canonical ABI — what a cross-component call costs.
- Browser sandbox security boundaries — the isolation model composition preserves.
← Back to Wasm Component Model & WIT Bindings