Component Model Definition

A WASM Component is:

  1. A .wasm binary that follows the Component Model spec (not a "core" wasm module)
  2. It declares typed imports (what it needs) and exports (what it provides)
  3. These types are defined in WIT (WebAssembly Interface Type) files
  4. Components can be composed, wired together at their import/export boundaries
  5. 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:

  1. WIT validation must come first. A typo in a WIT file produces inscrutable errors downstream. wasm-tools component wit --check catches them early.

  2. The WASI adapter is version-sensitive. TinyGo, Rust's wasm32-wasip2 target, 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:filesystem capability
  • 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.