Component Model Definition
A WASM Component is:
- A
.wasmbinary that follows the Component Model spec (not a "core" wasm module) - It declares typed imports (what it needs) and exports (what it provides)
- These types are defined in WIT (WebAssembly Interface Type) files
- Components can be composed, wired together at their import/export boundaries
- Each component has its own linear memory (sandboxed by default)
Here's a minimal WIT definition for an authentication component:
// auth.wit
package myorg:auth@1.0.0;
interface authenticate {
record auth-request {
token: string,
required-scopes: list<string>,
}
record auth-result {
authenticated: bool,
user-id: option<string>,
granted-scopes: list<string>,
error: option<string>,
}
verify: func(req: auth-request) -> auth-result;
}
world auth-component {
export authenticate;
}
And here's the Rust implementation:
// src/lib.rs
wit_bindgen::generate!({
world: "auth-component",
path: "../wit/auth.wit",
});
struct AuthComponent;
impl Guest for AuthComponent {
fn verify(req: AuthRequest) -> AuthResult {
let claims = match decode_jwt(&req.token) {
Ok(c) => c,
Err(e) => return AuthResult {
authenticated: false,
user_id: None,
granted_scopes: vec![],
error: Some(format!("JWT decode failed: {e}")),
},
};
let granted: Vec<String> = req.required_scopes
.iter()
.filter(|s| claims.scopes.contains(s))
.cloned()
.collect();
let all_granted = granted.len() == req.required_scopes.len();
AuthResult {
authenticated: all_granted,
user_id: Some(claims.sub.clone()),
granted_scopes: granted,
error: if all_granted { None } else {
Some("Insufficient scopes".into())
},
}
}
}
export!(AuthComponent);
The wit_bindgen::generate! macro reads the WIT file and generates all the type definitions and glue code. You implement the trait, export the struct, build it:
cargo component build --release
That produces a .wasm component (not a core module) that any Component Model runtime can load and call.
Cross-Language Composition: The Real Power
Here's where it gets interesting. Say your auth component is in Rust, but your API gateway is in Go, and your business logic is in Python (using componentize-py). Each is compiled to a separate WASM component.
First, the Go gateway component's WIT:
// gateway.wit
package myorg:gateway@1.0.0;
interface handle-request {
record http-request {
method: string,
path: string,
headers: list<tuple<string, string>>,
body: option<list<u8>>,
}
record http-response {
status: u16,
headers: list<tuple<string, string>>,
body: list<u8>,
}
handle: func(req: http-request) -> http-response;
}
world gateway-component {
// Import auth from the Rust component
import myorg:auth/authenticate@1.0.0;
// Import business logic from the Python component
import myorg:bizlogic/process@1.0.0;
export handle-request;
}
The Go implementation imports the auth interface and calls it like a local function, no HTTP, no serialization:
package main
import (
"myorg/gateway/gen"
auth "myorg/auth/gen"
bizlogic "myorg/bizlogic/gen"
)
type Gateway struct{}
func (g *Gateway) Handle(req gen.HttpRequest) gen.HttpResponse {
// Extract bearer token
token := extractBearerToken(req.Headers)
// Call the Rust auth component, same process, no network
authResult := auth.Verify(auth.AuthRequest{
Token: token,
RequiredScopes: []string{"read:users"},
})
if !authResult.Authenticated {
return gen.HttpResponse{
Status: 401,
Body: []byte(`{"error": "unauthorized"}`),
}
}
// Call the Python business logic component
result := bizlogic.Process(bizlogic.ProcessRequest{
UserId: *authResult.UserId,
Action: "list_users",
Payload: req.Body,
})
return gen.HttpResponse{
Status: 200,
Headers: []gen.Header{{"content-type", "application/json"}},
Body: result.Data,
}
}
Now compose them:
# Build each component
cargo component build --release -p auth-component
tinygo build -target=wasip2 -o gateway.wasm ./gateway
componentize-py -d ./wit -w bizlogic-component ./bizlogic.py -o bizlogic.wasm
# Compose into a single linked component
wasm-tools compose \
--definitions auth-component.wasm \
--definitions bizlogic.wasm \
-o composed-service.wasm \
gateway.wasm
The composed-service.wasm is a single binary containing all three components, with their imports/exports wired together. You can run it on wasmtime, Cloudflare Workers, Fermyon Spin, or any Component Model runtime.
The Build Pipeline in Detail
Getting this working in CI takes more effort than expected. Here is a Makefile that actually works:
WIT_DIR := ./wit
BUILD_DIR := ./build
WASI_ADAPTER := ./adapters/wasi_snapshot_preview1.reactor.wasm
.PHONY: all clean validate compose
all: validate compose
# Validate all WIT files first
validate:
wasm-tools component wit $(WIT_DIR) --check
# Build Rust auth component
$(BUILD_DIR)/auth.wasm: auth/src/lib.rs $(WIT_DIR)/auth.wit
cd auth && cargo component build --release
cp auth/target/wasm32-wasip2/release/auth_component.wasm $@
wasm-opt -Oz $@ -o $@
# Build Go gateway component
$(BUILD_DIR)/gateway.wasm: gateway/main.go $(WIT_DIR)/gateway.wit
cd gateway && tinygo build -target=wasip2 -o ../$@
wasm-tools component new $@ --adapt $(WASI_ADAPTER) -o $@
# Build Python bizlogic component
$(BUILD_DIR)/bizlogic.wasm: bizlogic/app.py $(WIT_DIR)/bizlogic.wit
componentize-py -d $(WIT_DIR) -w bizlogic-component bizlogic/app.py -o $@
# Compose everything
compose: $(BUILD_DIR)/auth.wasm $(BUILD_DIR)/gateway.wasm $(BUILD_DIR)/bizlogic.wasm
wasm-tools compose \
-d $(BUILD_DIR)/auth.wasm \
-d $(BUILD_DIR)/bizlogic.wasm \
-o $(BUILD_DIR)/composed-service.wasm \
$(BUILD_DIR)/gateway.wasm
@echo "Composed binary: $$(du -h $(BUILD_DIR)/composed-service.wasm | cut -f1)"
clean:
rm -rf $(BUILD_DIR)/*
Two important lessons:
-
WIT validation must come first. A typo in a WIT file produces inscrutable errors downstream.
wasm-tools component wit --checkcatches them early. -
The WASI adapter is version-sensitive. TinyGo, Rust's
wasm32-wasip2target, and componentize-py each expect a specific WASI preview version. The adapter binary should be pinned in the repo and referenced explicitly.
Performance: Tradeoff Analysis
Here are real numbers from a production deployment. The setup has 12 WASM components (mix of Rust, Go, and Python) composed into 3 services, running on a custom wasmtime 18 orchestrator on bare metal.
Component call overhead (inter-component function call):
| Payload size | Latency | Notes |
|---|---|---|
| Empty (no args) | 0.8μs | Baseline dispatch cost |
| 1KB struct | 2.1μs | Includes canonical ABI serialization |
| 64KB blob | 18μs | Dominated by memcpy between linear memories |
| 1MB blob | 156μs | Linear with size, as expected |
For context, a gRPC call over loopback is ~50μs for an empty message, and ~200μs for 1KB. So component calls are ~25x faster than gRPC for small payloads. The gap narrows with large payloads because both are bottlenecked on data copying.
CPU overhead vs native (compute-bound benchmark, no I/O):
| Component language | vs native | Notes |
|---|---|---|
| Rust → WASM | 1.15x | Minimal overhead, bounds checks |
| Go → WASM (TinyGo) | 1.45x | TinyGo's GC is simpler, some opts missing |
| Python → WASM | 3.2x | componentize-py includes CPython interpreter |
The Python number looks bad, but remember: this Python component runs in the same process, with no container overhead, no cold start, and full sandboxing. If the alternative is a Python container with 800ms cold start, 3.2x CPU overhead on a 5ms computation is a fantastic trade.
Memory footprint:
| Deployment | Memory per instance |
|---|---|
| 12 Docker containers (1 per component) | ~3.2GB total |
| 3 composed WASM services (4 components each) | ~420MB total |
| Single composed WASM (all 12 components) | ~180MB |
The memory savings are dramatic because WASM components share the runtime (wasmtime) and only allocate their own linear memory. A Rust component's linear memory starts at 64KB and grows on demand.
The Sandboxing Story
This is the part that sells security teams. Each WASM component:
- Cannot access the filesystem unless explicitly given a
wasi:filesystemcapability - Cannot make network calls unless given
wasi:sockets - Cannot read another component's memory (separate linear memories)
- Cannot execute arbitrary syscalls (WASI capability model)
Consider a third-party vendor providing a "data enrichment" component. In the container world, this would need to run in a separate network namespace, configure AppArmor/SELinux policies, set up egress filtering, etc. With WASM, the component can be composed into the service with zero capabilities, it can compute on the data passed to it and return results. That's it. If the code tries to phone home, the call fails at the WASI boundary, not at a network firewall.
# Compose vendor component with NO WASI capabilities
wasm-tools compose \
--definitions vendor-enrichment.wasm \
--definitions our-service.wasm \
-o final.wasm \
orchestrator.wasm
# The vendor component can only:
# - Receive data through its WIT-defined imports
# - Return data through its WIT-defined exports
# - Use CPU and its own linear memory
# It CANNOT: open files, make network calls, read env vars, spawn processes
Common Mistakes to Avoid
Mistake 1: Putting JSON parsing at the component boundary. A common first design has components exchange JSON strings. This meant every cross-component call paid for serialize + deserialize + two string allocations. Switching to WIT record types with the canonical ABI eliminated this entirely.
Mistake 2: One giant component instead of composition. A common initial approach compiles everything into a single WASM module (not component). This worked but lost all the sandboxing benefits and made updates impossible without redeploying everything. The Component Model's composition is the whole point, it lets you update one component without touching others.
Mistake 3: Ignoring the adapter version. Three days can be lost debugging a segfault that turned out to be a mismatch between the WASI preview 1 adapter and a component built for preview 2. Pin your adapter versions. Seriously.
When to Use This (and When Not To)
Use the WASM Component Model when:
- You need to compose code from multiple languages in a single process
- Security isolation between components is important
- You want high deployment density (many services, limited memory)
- Your services are compute-heavy with small data exchanges
Don't use it when:
- All your code is one language (just use that language's native tooling)
- You need to exchange large datasets between components (the copy overhead adds up)
- Your team isn't comfortable with the build toolchain complexity
- You need mature debugging/profiling tools (they're getting better, but not there yet)
The Component Model isn't replacing Kubernetes or Docker. It's a new tool for a specific class of problems, and for those problems, it's remarkably effective.