The Wasm Component Model in Production, A Practical Walkthrough
TL;DR — The Wasm component model is the typed, composable layer above core Wasm. WIT contracts define imports/exports.
wasm-tools composewires components without code. Version your WIT, validate on load, treat composition as a release-time deploy artifact.
The component model is the most underrated standard in systems programming this decade. WASI 0.2 finally shipped it, and the runtimes (Wasmtime 28, WasmEdge 0.14, Wasmer 4.4) all support it. But “supports the component model” is a feature checkbox. Running real systems on it is a discipline. This article is about the discipline.
We’re going to do this top-down. I’ll describe how I shape a real component-based system, then walk a concrete example that you can build today. If you want context on the broader runtime landscape first, the server-side Wasm survey is the right starting point. If you want the toolchain refresher, the cargo-component tutorial covers building components.
The cliff notes for skeptics: components let you ship typed contracts across language and team boundaries, compose multiple components into a single deployable, and version them like APIs. This is what gave Unix shared libraries their staying power, except this time the boundary is typed and sandboxed.
The Design Principles
I run component systems with five rules. They’ve held up across three production deployments.
- WIT is the API. Treat your
.witfiles like OpenAPI specs. Review them in PRs. Version them. Generate clients for any host language. - Components are small. One component, one responsibility. 100-500 KB compiled. If a component is 5 MB, it’s probably two components in a trenchcoat.
- Composition at release time. Use
wasm-tools composeto wire components together at CI time, not runtime. The final artifact is a single composed component. - No ambient capabilities. Every I/O capability is granted explicitly via the host. Components do not get “open any file.”
- Versioning is explicit. WIT packages carry semver. Components encode the WIT version. Hosts validate on load.
The rest of this article is each of those, made concrete.
A Concrete System
We’ll build a content pipeline. Two components and one host. Component A is a markdown component that exports a parse-and-render function. Component B is a sanitizer component that strips disallowed HTML tags. We’ll compose A with B so the rendered HTML is automatically sanitized, ship one artifact, run it from a Rust host.
+----------------------+
| composed.wasm |
| +------------------+ |
| | markdown | |
| | imports: | |
| | sanitize-html ---->+
| | exports: | |
| | process-doc | | |
| +------------------+ | |
| | | |
| +------------------+ | |
| | sanitizer |<+--+
| | exports: | |
| | sanitize-html | |
| +------------------+ |
+----------------------+
^
|
+-------+---------+
| Rust Host |
+-----------------+
The host calls process-doc on the composed artifact. The composed artifact internally calls sanitize-html between components. No host involvement in the wiring.
Step 1, Author the WIT
Two WIT files. Treat these as API contracts.
sanitizer/wit/world.wit:
package amal:sanitizer@0.1.0;
interface html {
variant policy {
strict,
permissive,
}
sanitize: func(input: string, p: policy) -> string;
}
world sanitizer {
export html;
}
markdown/wit/world.wit:
package amal:markdown@0.1.0;
interface processor {
record doc {
title: string,
body: string,
}
process-doc: func(d: doc) -> string;
}
world markdown {
import amal:sanitizer/html@0.1.0;
export processor;
}
Note the import. markdown does not implement HTML sanitization, it imports it from amal:sanitizer/html at the typed boundary. That’s the seam where composition happens.
Step 2, Implement the Sanitizer
cargo component new --lib sanitizer
# replace wit/world.wit
src/lib.rs:
#[allow(warnings)]
mod bindings;
use bindings::exports::amal::sanitizer::html::{Guest, Policy};
struct Component;
const ALLOWED_STRICT: &[&str] = &["p", "br", "em", "strong", "a"];
const ALLOWED_PERMISSIVE: &[&str] = &[
"p", "br", "em", "strong", "a", "h1", "h2", "h3", "ul", "ol", "li",
"code", "pre", "blockquote",
];
impl Guest for Component {
fn sanitize(input: String, p: Policy) -> String {
let allow: &[&str] = match p {
Policy::Strict => ALLOWED_STRICT,
Policy::Permissive => ALLOWED_PERMISSIVE,
};
strip_disallowed(&input, allow)
}
}
fn strip_disallowed(s: &str, allow: &[&str]) -> String {
// toy implementation, real sanitizer would use ammonia
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '<' {
let mut tag = String::new();
while let Some(&nc) = chars.peek() {
if nc == '>' { chars.next(); break; }
tag.push(nc);
chars.next();
}
let name = tag.trim_start_matches('/').split_whitespace().next().unwrap_or("");
if allow.iter().any(|a| a.eq_ignore_ascii_case(name)) {
out.push('<');
out.push_str(&tag);
out.push('>');
}
} else {
out.push(c);
}
}
out
}
bindings::export!(Component with_types_in bindings);
Build:
cargo component build --release
# target/wasm32-wasip2/release/sanitizer.wasm
Step 3, Implement the Markdown Component
cargo component new --lib markdown
Update Cargo.toml:
[dependencies]
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
src/lib.rs:
#[allow(warnings)]
mod bindings;
use bindings::amal::sanitizer::html::{sanitize, Policy};
use bindings::exports::amal::markdown::processor::{Doc, Guest};
use pulldown_cmark::{html, Options, Parser};
struct Component;
impl Guest for Component {
fn process_doc(d: Doc) -> String {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(&d.body, opts);
let mut html_out = String::new();
html::push_html(&mut html_out, parser);
let body = sanitize(&html_out, Policy::Permissive);
format!("<article><h1>{}</h1>{}</article>", escape(&d.title), body)
}
}
fn escape(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}
bindings::export!(Component with_types_in bindings);
Build:
cargo component build --release
# target/wasm32-wasip2/release/markdown.wasm
Inspect imports:
wasm-tools component wit target/wasm32-wasip2/release/markdown.wasm | head -20
# package root:component;
# world root {
# import amal:sanitizer/html@0.1.0;
# ...
# export amal:markdown/processor@0.1.0;
# }
The import line is what compose will fulfill.
Step 4, Compose the Components
wasm-tools compose \
-o composed.wasm \
--search-path . \
markdown/target/wasm32-wasip2/release/markdown.wasm \
-d sanitizer/target/wasm32-wasip2/release/sanitizer.wasm
Or via a compose manifest for repeatable builds. compose.yml:
dependencies:
"amal:sanitizer":
path: sanitizer/target/wasm32-wasip2/release/sanitizer.wasm
wasm-tools compose \
-c compose.yml \
-o composed.wasm \
markdown/target/wasm32-wasip2/release/markdown.wasm
Verify the composed artifact has no remaining sanitizer import:
wasm-tools component wit composed.wasm
# package root:component;
# world root {
# export amal:markdown/processor@0.1.0;
# }
That’s it. One file. Same bits regardless of which host loads it. The seam between markdown and sanitizer is no longer a host-side concern. This is the production payoff.
Step 5, Host the Composed Artifact
A minimal Rust host:
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};
wasmtime::component::bindgen!({
path: "../markdown/wit/world.wit",
world: "markdown",
async: false,
});
struct HostState { table: ResourceTable, wasi: WasiCtx }
impl WasiView for HostState {
fn table(&mut self) -> &mut ResourceTable { &mut self.table }
fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
}
fn main() -> anyhow::Result<()> {
let mut config = Config::new();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
let component = Component::from_file(&engine, "composed.wasm")?;
let mut linker = Linker::<HostState>::new(&engine);
wasmtime_wasi::add_to_linker_sync(&mut linker)?;
let mut store = Store::new(&engine, HostState {
table: ResourceTable::new(),
wasi: WasiCtxBuilder::new().inherit_stdio().build(),
});
let bindings = Markdown::instantiate(&mut store, &component, &linker)?;
let proc = bindings.amal_markdown_processor();
let html = proc.call_process_doc(&mut store, &Doc {
title: "Components in Production".into(),
body: "**Hello** _world_.\n\n<script>alert(1)</script>".into(),
})?;
println!("{html}");
Ok(())
}
The host knows about the markdown world only. It does not know sanitizer exists. The composed artifact made the seam internal.
Versioning and Compatibility
WIT packages carry semver: @0.1.0, @1.2.3. Components encode the WIT they were built against. The component model’s binding generators check exported and imported versions at instantiation. Mismatches surface as missing-export or version-mismatch errors at link time.
In practice I use these rules:
- Pre-1.0: bump minor on breaking changes (
0.1.xto0.2.x). - Post-1.0: bump major on breaking changes, minor on additive, patch on no-op.
- Components from different teams pin to specific WIT versions; the central WIT repo bumps versions like any other library.
wasm-tools component witextracts WIT from any compiled component. CI runs this and diffs against the expected WIT for the version.
The component model docs at component-model.bytecodealliance.org cover the versioning semantics in depth.
Operational Patterns
A few things that took me time to figure out, written down so you don’t have to.
Composed artifacts in registries. Push composed .wasm files to OCI registries via wkg or oras. Tag with semver. Deploy by pulling tag. Your existing OCI tooling works.
Composition in CI, not at runtime. Don’t compose on hosts. Composition is a build step. The output is the deployable.
Auditability. Run wasm-tools component wit on every composed artifact in CI and check it into the build log. When you debug “what was actually shipped to prod,” you can answer.
Polyglot components. A composed artifact can contain Rust, JavaScript, and Python components composed together. componentize-js and componentize-py produce components that compose with cargo-component output. Useful for teams with mixed stacks.
Resource lifecycle. WIT resources (resource handle<T>) cross component boundaries cleanly, but ownership is the host’s job. If component A returns a resource to the host and the host passes it to component B, A keeps a reference. The pattern is usually fine but requires thought for long-lived handles.
Resource Types and Lifecycle
WIT supports resource types, which represent opaque handles to host-managed objects. They’re how you model things like database connections, file handles, or any stateful object the component holds across function calls. Resources have constructors (constructor), static methods, and instance methods. The component sees a handle; the host owns the underlying state.
interface db {
resource connection {
constructor(url: string);
query: func(sql: string) -> list<row>;
close: func();
}
record row { columns: list<string> }
}
On the host side, you implement a Host trait that maps connection handles to actual database connections in a ResourceTable. When the component calls connection::query, the host looks up the connection by handle and runs the query. When the component drops the handle, the host’s destructor runs and the connection closes.
The lifecycle semantics are strict. A component owns the resources it creates. Handles can be passed across component boundaries via WIT but ownership transfers explicitly. If you have circular references between two components and both think they own the resource, you’ll get use-after-free at the host layer. Design resources with single-owner semantics in mind.
For HTTP, WASI 0.2 uses resources extensively: incoming-request, outgoing-response, incoming-body. The streaming body APIs are resource-based, which is why HTTP bindings can stream gigabytes without materializing the whole body in linear memory. This is the right design but it takes a beat to internalize coming from “everything is a value type” thinking.
Common Pitfalls
WIT name collisions across packages. Two packages exporting interface storage cause ambiguity at compose time. Always qualify with the package name in imports (import amal:sanitizer/html, not import html).
Composition fails with “unresolved imports”. The dependency you passed to compose didn’t export the interface the dependent imports. Re-check the package and interface names match exactly. WIT is whitespace-tolerant but name-strict.
Cyclical composition. A imports from B, B imports from A. The composer rejects this. It’s a design smell anyway. Refactor to break the cycle.
Forgetting that cargo component build uses cached WIT. If you update a dependency’s WIT, you may need cargo component update (or just delete target/wit/) to pick up the new shape. Otherwise builds succeed with stale bindings and you debug for an hour.
Troubleshooting
Composed artifact larger than expected. Components are statically linked at compose. If markdown and sanitizer both pull in std formatting, you pay for both. Use panic = "abort", strip, and check with twiggy top composed.wasm.
Runtime trap “wrong number of return values”. Almost always a WIT mismatch between the binding generator on host and the actual component. Re-run bindgen! after WIT changes and rebuild the host.
Slow first call. AOT-compile composed artifacts. wasmtime compile composed.wasm produces composed.cwasm that loads in under a millisecond. Refer to the wasmtime docs for the precompile workflow.
Distributing Components
Components belong in a registry, like containers. The OCI artifact spec accommodates Wasm artifacts natively. The tooling stack as of February 2025 is wkg for package management and wac (WebAssembly Composition tool) for composition with versioned packages from registries. wkg push ghcr.io/your-org/markdown:0.1.0 markdown.wasm pushes a component to a registry; wkg pull retrieves it; wac compose resolves a composition manifest against a registry to produce the final artifact.
This matters for organizations with many teams shipping components. Team A publishes auth/jwt-verifier:1.3.0. Team B’s service composes against it by declaring the dependency in wac.toml and pinning the version. CI verifies the composition, runs tests, and ships. When Team A releases 1.4.0, Team B’s CI re-resolves, re-tests, and ships if everything passes. This is the dependency hygiene that npm and crates.io give you for libraries, finally applied to Wasm components.
The registry story is still maturing. GHCR works. Bytecode Alliance Foundation runs wa.dev as a community registry. Most teams I see in early 2025 self-host with Harbor or use GHCR. Pick whatever your container tooling already supports; Wasm artifacts are just OCI artifacts with a different media type.
For internal projects without registry overhead, the workspace-with-path-deps pattern is fine. Cargo.toml workspace, each component its own crate, WIT shared via a wit/ directory at the workspace root. cargo-component resolves paths. Composition happens at build time via a CI script. Not as clean as a registry but no extra infrastructure to run.
Wrapping Up
The component model rewards the teams that treat it like a real API surface. Version your WIT. Compose at release time. Use OCI to distribute. Keep components small. Done well, you get the typed, sandboxed, language-agnostic boundary that was promised. The next article in the series takes us to the edge, where the component model and Wasm’s cold-start story meet at Cloudflare Workers and Fastly Compute.