<!-- Canonical: https://lucioduran.com/blog/rust-wasm-component-model-production
Language: en
Content-SHA256: 3127a0b6cb6cdf1f27e60c18b54d1ba2c990098844971ea714d352b8533c2d1b
-->

WebAssembly

# Rust + WASM Component Model in Production: Polyglot Microservices That Actually Work

2025-10-088 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/rust-wasm-component-model-production>)

## Component Model Definition

A [WASM Component](<https://component-model.bytecodealliance.org/>) 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:

```wit
// 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:

```rust
// 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:

```bash
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:

```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:

```go
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:

```bash
# 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](<https://cloudflare.com/>) 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:

```makefile
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.

```bash
# 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.

## Frequently asked questions

### Is the WASM Component Model ready for production use?

For specific use cases, yes. We've been running Rust WASM components on Cloudflare Workers and in a custom wasmtime-based orchestrator since Q3 2025. The Component Model spec reached 1.0 in mid-2025, and tooling like wasm-tools 1.2+ and cargo-component 0.16+ is stable. The gap is in ecosystem maturity, most libraries don't expose WIT interfaces yet.

### How does cross-language composition actually work?

Each component is a self-contained .wasm binary with typed imports and exports defined in WIT. A Go component can import a function exported by a Rust component, and the runtime (wasmtime, wamr, etc.) handles the ABI translation. The key constraint is that components don't share memory, all data crossing boundaries is copied through the canonical ABI.

### What's the performance overhead compared to native microservices?

For CPU-bound logic, benchmarks show 1.2-1.8x overhead vs native Rust, mostly from bounds checking and indirect calls. For I/O-bound services (which is most real-world code), the overhead is negligible because you're waiting on network/disk anyway. The win is in deployment density, 40 WASM components can run in the memory footprint of 8 native containers.

## Tips

1. **Pin your WIT versions in a git submodule:** WIT interfaces are your API contracts. Put them in a dedicated repo and reference as a submodule. When you run ` wasm-tools component new `, pass ` --adapt wasi_snapshot_preview1=adapter.wasm ` to pin the WASI adapter version too. Version drift between WIT and adapter causes silent ABI mismatches.
2. **Use ` cargo component build --release ` with LTO:** The default debug build produces .wasm files 5-10x larger. Adding ` lto = true ` and ` opt-level = 's' ` in your Cargo.toml release profile, combined with ` wasm-opt -Oz `, typically gets a component from 2MB to 180KB.
3. **Avoid ` String ` in WIT interface boundaries:** Every string crossing the component boundary requires an allocation + copy because components have separate linear memories. Use ` list<u8> ` with a known encoding, or better, define ` record ` types with fixed-size fields. Production data shows a 40% latency reduction on a JSON-heavy interface by switching to structured records.
4. **Test component linking locally with ` wasmtime compose `:** Before deploying, run ` wasm-tools compose -d deps/ -o linked.wasm your-component.wasm ` to catch interface mismatches at build time. The error messages from runtime linking are cryptic; the compose tool gives you clear diagnostics.

- rust
- webassembly
- component-model
- wit
- wasm-tools
- microservices

[Back to Blog](<https://lucioduran.com/blog>)
