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.

An import satisfied at link time, not run time The application component imports an interface that the codec component exports. Composing them produces one artifact in which that connection is already made, so the host only sees the outer world and never wires the two together itself. app.wasm imports example:codec/decode exports app:main/run incomplete on its own codec.wasm exports example:codec/decode imports nothing self-contained composed.wasm exports app:main/run imports nothing the codec is inside compose Each component keeps its own linear memory inside the composite — they are isolated from one another, not merged. Values crossing between them go through the canonical ABI, exactly as they would across a host boundary.

Prerequisites

  • [ ] wasm-tools with the compose subcommand, or wac for more complex graphs
  • [ ] Two components whose WIT package identifiers and versions match exactly
  • [ ] A host that can run the composite — wasmtime, or jco for 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.

Compose ahead of time, or wire at run time Composition produces one artifact with the dependency resolved and type-checked at link time. Host wiring keeps the components separate and lets the host substitute implementations, at the cost of connecting them correctly on every startup. compose ahead of time one artifact to ship and version types checked at link time host sees a simple world the default choice wire in the host implementations swappable at run time each part updatable independently host must get the wiring right for plugin architectures Compose when the dependency is fixed; wire when the point of the interface is that it can be replaced.

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.

What has to match, exactly The importing side and the exporting side must agree on namespace, package name, version and interface name. A difference in any one of them leaves the import unsatisfied, and the composition tool reports it rather than guessing. import example:codec/decode@0.1.0 what app.wasm asks for export example:codec/decode@0.1.0 what codec.wasm offers …@0.2.0 — a different interface no match — the import stays unsatisfied Sharing one WIT package between both builds removes this class of mismatch entirely, which is why it is worth the extra dependency.

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.

← Back to Wasm Component Model & WIT Bindings