Different Language

[WASM Component Model in Practice, Part 2: Polyglot Implementation, Composition, and Production Trade-offs]

Analyze with AI

Get AI-powered insights from this Mad Devs tech article:

Part 1 covered the architecture, the toolchain, and the WIT interface design. We defined a document processing pipeline with three worlds — validator-world, transformer-world, and pipeline-world — targeting components implemented in Go/TinyGo, Rust, and Python, respectively. This part implements all three, composes them into a single binary, and examines where the Component Model fits cleanly into production architecture and where it does not yet.

Implementing the same WIT contract in Go, Python, and Rust

All three implementations target the WIT from Part 1. The type system is the same. The function signatures are the same. The language idioms differ.

Go / TinyGo: the validator component

TinyGo 0.34.0 introduced native WASI 0.2 and Component Model support. The workflow no longer requires the Preview 1 adapter step that earlier versions needed.

Before reading the code below, read this. Go bindings for the Component Model are generated by wit-bindgen-go, which lives in the bytecodealliance/go-modules repository — separate from the wit-bindgen-cli used for Rust and WIT inspection. The older TinyGo WIT bindings generator at go.bytecodealliance.org is no longer maintained; the current path is the Guest: Go generator in go-modules. Install and use it according to the go-modules documentation; do not assume the CLI commands or generated API shape match the Rust-oriented wit-bindgen CLI.

The code below is generated-binding-shaped: it shows the structural pattern accurate for wit-bindgen-go at the time of writing, but the exact type names (gen.Validator, gen.SetValidator, gen.TypesValidationResult) are generated output that changes between releases. This workflow is reproducible only if you first run wit-bindgen go, inspect the actual generated package, and adapt the implementation to the generated names. Treat the code below as a reference pattern, not a copy-paste starting point.

Additionally, verify that your TinyGo version supports exporting the specific custom world you target. TinyGo 0.34+ reliably handles WASI command-style worlds; arbitrary application worlds with custom WIT exports may require additional binding setup depending on your exact toolchain state.

# Step 1: generate bindings -- inspect the output before writing implementation
wit-bindgen go \
  --world validator-world \
  ../../wit \
  --out-dir gen

# The generated package will contain:
# gen/validator.go     -- the Validator interface you must implement
# gen/types.go         -- generated types (Document, ValidationError, etc.)
# gen/validator_host.go -- SetValidator() registration function
# Exact names vary by wit-bindgen version -- check the generated files
ls gen/
// components/validator/main.go
// Written against generated bindings from wit-bindgen 0.32.0 + TinyGo 0.34.0
// If your generated types differ, adjust the implementation accordingly

package main

// Import the generated package -- path depends on your Go module name
import (
	"my-org/doc-pipeline/components/validator/gen"
)

// Compile-time check: ValidatorImpl must satisfy the generated interface.
// If this line fails, the generated interface name differs from gen.Validator.
var _ gen.Validator = ValidatorImpl{}

type ValidatorImpl struct{}

func (v ValidatorImpl) Validate(doc gen.TypesDocument) gen.TypesValidationResult {
	if len(doc.Content) == 0 {
		return gen.TypesValidationResult{
			IsOk: false,
			Err: &gen.TypesValidationError{
				Tag: gen.TypesValidationErrorTagSchemaViolation,
				Val: "content must not be empty",
			},
		}
	}

	const maxBytes = 10 * 1024 * 1024
	if uint64(len(doc.Content)) > maxBytes {
		return gen.TypesValidationResult{
			IsOk: false,
			Err: &gen.TypesValidationError{
				Tag: gen.TypesValidationErrorTagSizeLimitExceeded,
				Val: uint64(len(doc.Content)),
			},
		}
	}

	return gen.TypesValidationResult{IsOk: true, Ok: &doc}
}

func (v ValidatorImpl) ValidateBatch(docs []gen.TypesDocument) []gen.TypesValidationResult {
	results := make([]gen.TypesValidationResult, len(docs))
	for i, doc := range docs {
		results[i] = v.Validate(doc)
	}
	return results
}

func init() {
	// Register implementation with the generated glue code.
	// The exact function name (SetValidator) is generated by wit-bindgen.
	gen.SetValidator(ValidatorImpl{})
}

func main() {}
# components/validator/Makefile
# TinyGo 0.34+ native Component Model -- no Preview 1 adapter needed

WORLD    = validator-world
WIT_DIR  = ../../wit
GEN_DIR  = gen

.PHONY: bindings build validate

bindings:
	wit-bindgen go \
		--world $(WORLD) \
		$(WIT_DIR) \
		--out-dir $(GEN_DIR)
	@echo "Generated bindings in $(GEN_DIR)/ -- review before implementing"

build: bindings
	tinygo build \
		-target wasip2 \
		-o ../../composed/validator.wasm \
		.
	@echo "Validator component built: ../../composed/validator.wasm"

validate:
	wasm-tools validate ../../composed/validator.wasm --features component-model
	wasm-tools component wit ../../composed/validator.wasm

Note the target: -target wasip2 for TinyGo 0.34+. Earlier versions used wasip1 with a separate adapter step; 0.34 targets WASI Preview 2 (and thus Component Model) natively.

Rust: the transformer component

Rust has the most mature Component Model support. The wasm32-wasip2 target has been tier 2 in Rustc since version 1.82 (October 2024), which means it ships with every Rust release and is tested in CI. Unlike the older wasm32-wasip1 path — which compiled to a core WASM module and then required a post-processing step with wasm-tools component new and a WASI Preview 1 adapter — wasm32-wasip2 outputs a component directly. No adapter, no wrapping step.

The wit-bindgen Rust crate generates a macro-based binding that reads your WIT at compile time and produces a trait you implement. The generated trait has one method per function in the WIT interface, with Rust types matching the WIT types. The compiler enforces that your implementation matches the interface.

Add wasm32-wasip2 as a target if you have not already:

rustup target add wasm32-wasip2
# components/transformer/Cargo.toml
[package]
name = "transformer"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = "0.32"

[profile.release]
opt-level = "s"
lto = true
strip = true
// components/transformer/src/lib.rs

wit_bindgen::generate!({
    path: "../../wit",
    world: "transformer-world",
});

use exports::my_org::doc_pipeline::transformer::{Document, Guest};

struct TransformerImpl;

impl Guest for TransformerImpl {
    fn transform(doc: Document, rules: Vec<String>) -> Option<Document> {
        let mut content = doc.content.clone();

        for rule in &rules {
            match rule.as_str() {
                "uppercase" => content = content.to_uppercase(),
                "trim" => content = content.trim().to_string(),
                "strip-html" => content = strip_html_tags(&content),
                unknown => {
                    eprintln!("transformer: unknown rule '{}'", unknown);
                }
            }
        }

        if content.is_empty() {
            None
        } else {
            Some(Document {
                id: doc.id,
                content,
                metadata: doc.metadata,
            })
        }
    }
}

fn strip_html_tags(input: &str) -> String {
    let mut result = String::with_capacity(input.len());
    let mut inside_tag = false;
    for c in input.chars() {
        match c {
            '<' => inside_tag = true,
            '>' => inside_tag = false,
            _ if !inside_tag => result.push(c),
            _ => {}
        }
    }
    result
}

export!(TransformerImpl);
# Build the Rust transformer component
cargo build --target wasm32-wasip2 --release
cp target/wasm32-wasip2/release/transformer.wasm ../../composed/transformer.wasm

# Validate the component's interface matches the WIT
wasm-tools validate ../../composed/transformer.wasm --features component-model
wasm-tools component wit ../../composed/transformer.wasm

The last command prints the component's embedded WIT, extracted from the binary. This is the ground truth — if it does not match your .wit file, the bindings or the build configuration went wrong.

Why wasip2 for Rust and wasip2 for TinyGo. Both languages target wasm32-wasip2 / -target wasip2 directly, producing component binaries without an adapter step. The older approach — compiling to wasm32-wasip1, then wrapping with wasm-tools component new --adapt — still works and is documented in many tutorials. We use the direct path because it is simpler and produces the same output.

Python: the pipeline component

componentize-py compiles a Python module into a WASM component by embedding a CPython interpreter. The import structure in your Python code depends on what componentize-py generates for your WIT world — always run the bindings generation step first and check the output before writing implementation code.

# Step 1: generate Python bindings from the WIT
# This creates a Python package you import in your implementation
componentize-py \
  --wit-path ../../wit \
  --world pipeline-world \
  bindings \
  bindings/

# Inspect what was generated -- package and class names come from here
ls bindings/
# Expect something like:
# bindings/my_org/doc_pipeline/
#   types.py        -- Document, ValidationError, etc.
#   validator.py    -- imported validator interface
#   transformer.py  -- imported transformer interface
# components/pipeline/app.py
# IMPORTANT: The imports below are illustrative.
# Use the generated bindings package as the source of truth for actual import paths.
# Generated package structure and class names depend on componentize-py version and WIT layout.
# Run `componentize-py bindings` first and inspect the output before writing this file.

# Generated types from the WIT 'types' interface
from bindings.my_org.doc_pipeline.types import Document

# Generated stubs for imported interfaces (validator and transformer)
# These are satisfied at composition time by the validator and transformer components
from bindings.my_org.doc_pipeline.validator import validate, validate_batch
from bindings.my_org.doc_pipeline.transformer import transform


class Pipeline:
    """
    Implements the 'process' export from pipeline-world.
    The class name must match what componentize-py expects for this world --
    check the generated bindings for the exact convention.
    """

    def process(self, docs: list[Document]) -> list[Document]:
        results = []

        for doc in docs:
            # Call the validator -- imported from the composed validator component
            validation = validate(doc)

            # result<document, validation-error> -- check is_ok before accessing value
            if not validation.is_ok:
                # In production, surface per-document errors rather than silently skipping
                print(f"pipeline: validation failed for {doc.id}")
                continue

            validated_doc = validation.value

            # Call the transformer -- imported from the composed transformer component
            transformed = transform(validated_doc, ["trim", "strip-html"])

            # option<document> -- None means transformation produced no output
            if transformed is not None:
                results.append(transformed)

        return results
# Step 2: compile the Python module into a WASM component
# At this point, validator and transformer imports are unsatisfied --
# the component references them but does not bundle them.
# Composition in the next step satisfies those imports.
componentize-py \
  --wit-path ../../wit \
  --world pipeline-world \
  componentize app \
  --output ../../composed/pipeline-pre-compose.wasm

# Validate the output
wasm-tools validate ../../composed/pipeline-pre-compose.wasm --features component-model
wasm-tools component wit ../../composed/pipeline-pre-compose.wasm
# Output should show: imports validator + transformer, exports process

On binary size. Python components embed CPython: expect ~20 MB before any optimization. This is a real trade-off — covered in the performance section. If binary size is a constraint, Python is not the right language for this component.

wasm-tools compose takes a composition graph and produces a single composed .wasm file with all inter-component imports satisfied by linking.

Note on composition tooling. The Bytecode Alliance ecosystem is moving toward WAC-based composition in some guides — wac plug and WAC files make the dependency graph more declarative. This guide uses wasm-tools compose because the compose.yaml format makes imports and exports explicit, which is useful when learning how the dependency graph works. If your toolchain version recommends wac plug, the composition model is the same; only the configuration format differs.

# Install WAC
cargo install wac-cli

# Compose: plug validator and transformer into the pipeline
# The pipeline component is the "socket" (it imports validator + transformer)
# The validator and transformer components are the "plugs"
wac plug \
  composed/pipeline-pre-compose.wasm \
  --plug composed/validator.wasm \
  --plug composed/transformer.wasm \
  -o composed/pipeline.wasm

# Validate the result
wasm-tools validate composed/pipeline.wasm --features component-model
wasm-tools component wit composed/pipeline.wasm
# Should show: no unsatisfied imports, exports 'process'

wasm-tools compose (for complex dependency graphs). Uses a YAML config file with explicit search paths and dependency mappings. Useful when the composition involves many components or non-obvious wiring.

# compose.yaml
# The root component is passed as a CLI argument, not in this file.
# search-paths: directories where wasm-tools looks for dependency components
search-paths:
  - composed

# definitions: paths to components that provide implementations
# These are string paths, not name/path objects.
definitions:
  - composed/validator.wasm
  - composed/transformer.wasm
wasm-tools compose \
  composed/pipeline-pre-compose.wasm \
  --config compose.yaml \
  --output composed/pipeline.wasm

Note on config format.
The wasm-tools compose YAML schema uses definitions as a list of file paths (strings), search-paths as directories to scan, and dependencies as a map for explicit import-to-path mappings. The format has changed between versions; check wasm-tools compose --help for the syntax supported by your installed version.

Type mismatch errors. The most common. They look like:

error: type mismatch for import `my-org:doc-pipeline/validator`
expected type `(record (field "id" string) ...)`
found type `(record (field "id" string) ...)`

Two definitions that look identical in the WIT source are not the same type if they come from different packages or different versions. The fix is always the same: ensure both sides import the shared types interface from the same package, not define it locally.

Missing adapter errors. If a component imports wasi:logging/logging and the composition does not provide it, compose will fail with an unsatisfied import. You have three options: provide a concrete implementation component, stub it with a no-op adapter, or remove the import from the WIT world.

Circular dependency errors. Component A imports from B, and B imports from A. wasm-tools compose does not support cycles. Restructure the WIT world so that shared state flows through a third component that neither A nor B imports from.

Version skew. A component compiled with wit-bindgen 0.26 that imports an interface from a component compiled with 0.28 may produce a binary-level type mismatch even if the WIT source is identical. Pin all components in a composition graph to the same toolchain version.

Testing and validating components before and after composition

Test at three levels: structural validity (is the binary a valid component?), interface correctness (does it export what the WIT says?), and behavioral correctness (does it produce correct output?).

Structural and interface validation

# Is this a valid WASM component?
wasm-tools validate composed/validator.wasm --features component-model

# What does the component actually export?
# Use this to verify -- do not expect byte-for-byte equality with your source WIT.
# The extracted WIT is normalized and may have a different structure.
# What you are checking: the world name, the exported interfaces, and the type shapes.
wasm-tools component wit composed/validator.wasm

# Run the same check on the composed pipeline
wasm-tools validate composed/pipeline.wasm --features component-model
wasm-tools component wit composed/pipeline.wasm
# Should show: imports (validator, transformer) satisfied, exports process

On WIT diffing. Do not diff the extracted WIT against your source world.wit directly — the normalized output will not match byte-for-byte. Instead, read the extracted WIT and verify that the exported world, interfaces, and function signatures are what you intended.

Running components with a Wasmtime host

The Wasmtime CLI can validate and inspect components. Recent Wasmtime versions (25+) have improved --invoke support for component exports, including some structured types. However, for complex WIT record types and thorough integration testing, a small host program using the Wasmtime component embedding API is more reliable and gives you full control over inputs, assertions, and error reporting.

The recommended testing path is a Rust host:

// test-host/src/main.rs
// Pin wasmtime and wasmtime-wasi to the same version as your tested environment.
// The WasiView trait and linker API change between major versions.
//
// Cargo.toml:
// [dependencies]
// wasmtime = "25.0.0"
// wasmtime-wasi = "25.0.0"
// anyhow = "1"

use wasmtime::component::{Component, Linker, bindgen};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::{WasiCtxBuilder, WasiView, ResourceTable};

// bindgen! generates Rust types matching the WIT world at compile time.
// path must point to your WIT directory.
bindgen!({
    path: "../wit",
    world: "pipeline-world",
});

struct HostState {
    table: ResourceTable,
    wasi: wasmtime_wasi::WasiCtx,
}

impl WasiView for HostState {
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
    fn ctx(&mut self) -> &mut wasmtime_wasi::WasiCtx { &mut self.wasi }
}

fn main() -> anyhow::Result<()> {
    let mut config = Config::new();
    config.wasm_component_model(true);
    config.async_support(false);

    let engine = Engine::new(&config)?;
    let component = Component::from_file(&engine, "../composed/pipeline.wasm")?;

    let mut linker: Linker<HostState> = Linker::new(&engine);

    let wasi = WasiCtxBuilder::new().inherit_stdio().build();
    let mut store = Store::new(&engine, HostState {
        table: ResourceTable::new(),
        wasi,
    });

    let (pipeline, _) = PipelineWorld::instantiate(&mut store, &component, &linker)?;

    let docs = vec![
        Document { id: "doc1".into(), content: "hello world".into(), metadata: vec![] },
    ];

    let results = pipeline.my_org_doc_pipeline_pipeline().call_process(&mut store, &docs)?;

    for result in &results {
        println!("Result: id={}, content={}", result.id, result.content);
    }

    Ok(())
}

Note on the simplified host. Because the minimal WIT worlds have no host imports (no WASI, no logging), the linker requires no additional wiring. If you extend the WIT with import wasi:logging/logging, add wasmtime_wasi::add_to_linker_sync(&mut linker)? and ensure the WASI context is configured to satisfy it. Each host import you add requires explicit linker registration.

Plugin system architecture: a platform service with runtime-loadable components

The Component Model's primary production use case is plugin systems: a host platform that exposes a stable WIT interface, and third-party components that implement it — loaded at runtime, isolated from each other, written in any language.

┌──────────────────────────────────────────────────────────┐
│  Platform host (Rust + Wasmtime)                         │
│                                                          │
│  ┌─────────────────────┐   ┌─────────────────────────┐   │
│  │  Plugin registry    │   │  Host API (WIT-defined) │   │
│  │  - load(path)       │   │  - key-value store      │   │
│  │  - unload(id)       │   │  - logging              │   │
│  │  - call(id, input)  │   │  - http-client          │   │
│  └──────────┬──────────┘   └────────────┬────────────┘   │
│             │                           │                │
│  ┌──────────▼───────────────────────────▼────────────┐   │
│  │              Wasmtime component runtime           │   │
│  └──────────┬────────────────────────────────────────┘   │
│             │  isolated instances                        │
│    ┌────────┴──────────┬──────────────────┐              │
│    │                   │                  │              │
│  ┌─▼──────────┐  ┌─────▼──────┐  ┌───────▼───────┐       │
│  │ plugin-A   │  │  plugin-B  │  │   plugin-C    │       │
│  │ (Go/TinyGo)│  │  (Rust)    │  │   (Python)    │       │
│  └────────────┘  └────────────┘  └───────────────┘       │
└──────────────────────────────────────────────────────────┘

The plugin contract in WIT:

// wit/plugin-interface.wit
package my-platform:[email protected];

interface plugin {
  // Every plugin must export these
  record plugin-metadata {
    name: string,
    version: string,
    capabilities: list<string>,
  }

  record plugin-input {
    event-type: string,
    payload: list<u8>,  // opaque bytes -- the platform does not interpret
  }

  variant plugin-result {
    success(list<u8>),
    failure(string),
    not-applicable,  // plugin signals it does not handle this event type
  }

  metadata: func() -> plugin-metadata;
  handle: func(input: plugin-input) -> plugin-result;
}

world plugin-world {
  // What the platform provides to plugins
  import my-platform:platform/[email protected];
  import wasi:logging/[email protected];

  // What every plugin must provide
  export plugin;
}

Loading plugins at runtime in the Wasmtime host:

use wasmtime::component::{Component, Linker};
use wasmtime::{Engine, Store};
use std::collections::HashMap;

pub struct PluginRegistry {
    engine: Engine,
    linker: Linker<HostState>,
    plugins: HashMap<String, Component>,
}

impl PluginRegistry {
    pub fn load(&mut self, id: &str, wasm_path: &str) -> anyhow::Result<()> {
        let bytes = std::fs::read(wasm_path)?;
        wasmtime::component::Component::validate(&self.engine, &bytes)?;

        let component = Component::from_binary(&self.engine, &bytes)?;

        self.plugins.insert(id.to_string(), component);
        Ok(())
    }

    pub fn call(
        &self,
        id: &str,
        input: PluginInput,
        store: &mut Store<HostState>,
    ) -> anyhow::Result<PluginResult> {
        let component = self.plugins.get(id)
            .ok_or_else(|| anyhow::anyhow!("plugin '{}' not found", id))?;

        let instance = MyPlatformPlugin::instantiate(store, component, &self.linker)?;
        let result = instance.my_platform_plugin_plugin()
            .call_handle(store, &input)?;

        Ok(result)
    }
}

Memory isolation. Each plugin instance runs in its own linear memory sandbox. A plugin cannot read or corrupt the host’s memory or another plugin’s memory. A plugin that panics or traps does not crash the host — the host catches the trap and reports it. This is the primary safety guarantee that makes runtime-loadable untrusted plugins feasible.

Performance overhead, memory isolation, and what Component Model still does not solve

Call overhead

Every call across a component boundary goes through the Canonical ABI: type checking, memory layout transformation, and in some cases, memory copying. This is not free.

These numbers are approximate and directional. They were taken on Wasmtime 22.x (M2 MacBook, 2024); your results will differ with Wasmtime 25.x and with different workloads. Re-measure on your actual runtime version and component complexity before using these as capacity planning inputs.

OPERATION APPROXIMATE OVERHEAD VS NATIVE CALL
Integer/float args only ~50–200 ns
String argument (< 1 KB) ~500 ns–2 µs
Large payload (1 MB list) ~5–15 ms (dominated by copy)
Component instantiation ~1–10 ms per instance

The call overhead is negligible for coarse-grained operations (process a document, handle a plugin event). It becomes significant if you are crossing component boundaries in a tight inner loop at high frequency. Design WIT interfaces for batching — validate-batch over validate called a thousand times is the correct pattern.

Component instantiation cost. Instantiating a component (linking, memory allocation, WASI setup) takes milliseconds. For a plugin system handling many short-lived requests, pre-instantiate a pool of component instances and reuse them. Wasmtime's InstancePre API pre-links the component so that instantiation is cheaper at call time.

Memory model

Each component instance has its own linear memory. Memory is not shared across components — the Component Model deliberately prohibits it. This means:

  • A large payload passed between components is copied, not referenced
  • There is no shared memory for lock-free data structures across components
  • Python components (with embedded CPython) consume significantly more memory per instance than Rust or Go/TinyGo components

Python component memory footprint: ~20–50 MB per instance (CPython interpreter + standard library). Rust component: ~1–5 MB. TinyGo component: ~2–8 MB. If you are running many plugin instances concurrently, the language choice for each component has direct infrastructure cost implications.

What Component Model does not yet solve

Async across component boundaries. WASM component model async (based on WIT async and the forthcoming async in WIT 0.3) is not yet stable. Cross-component async calls today require synchronous blocking or a host-level event loop that the components poll. For I/O-heavy workloads, this is a significant limitation.

Streaming types in WIT. Passing large binary payloads efficiently — streaming a file, chunked HTTP response — requires the forthcoming stream<T> type in WIT 0.3. Today, large payloads pass as list<u8>, which requires materializing the full payload in memory before crossing the boundary.

Component versioning and hot reload. The Component Model specifies how components are composed at build time. Runtime component replacement (hot reload, plugin updates without restart) is a host runtime concern, not a Component Model concern. Each runtime implements it differently — or does not implement it at all. Wasmtime’s approach requires dropping and re-creating component instances.

Debugging across component boundaries. Stack traces from a composed component show the host-level trap, not the source-level location in the component that caused it. DWARF debugging information is embedded per-component but not automatically surfaced by the host runtime. Cross-component distributed tracing requires explicit propagation (inject a trace context into the payload before calling across the boundary).

When Component Model fits your architecture, and when it does not

Use it when:

  • You are building a plugin or extension system with third-party contributors. The memory isolation and type-checked interfaces mean you can accept plugins from external teams without trusting their code not to corrupt your process. The WIT contract gives plugin authors a stable, language-agnostic target.
  • You need polyglot component reuse. A Rust component implementing a validated cryptographic operation can be composed into a Go service and a Python pipeline without reimplementing the logic in each language. You write the implementation once; wit-bindgen generates the language-specific bindings for each consumer automatically from the shared WIT contract.
  • You want build-time interface validation for distributed systems. If two services must agree on an interface, encoding it in WIT and validating composition at build time catches mismatches earlier than discovering them at runtime via HTTP 400 responses.
  • Your host runtime already supports it. Spin (Fermyon), wasmCloud, and WAMR have production Component Model support. If your deployment target is one of these, the operational complexity is managed for you.

Be cautious when:

  • Your components need to share memory or communicate at high frequency. The Canonical ABI’s copy semantics add latency that accumulates quickly in tight loops. Within a single service, function calls within a single process are still faster.
  • Your team is not familiar with the toolchain. The version compatibility requirements, the WASI adapter dependencies, and the gap between WIT 0.1 and WIT 0.2 semantics all require dedicated learning time. Budget it explicitly before committing to the Component Model for a production milestone.
  • You need async-first I/O across component boundaries. WIT async is not yet stable. If your components are primarily doing network I/O, the current synchronous model will produce awkward patterns or require host-level workarounds.
  • Your binary size budget is tight. A composed pipeline with a Python component (20 MB), a Rust component (2 MB), and a TinyGo component (4 MB) produces a binary larger than many container images for equivalent workloads. For edge deployments with strict size limits, Rust-only or TinyGo-only compositions are significantly smaller.

The adoption path that works

Start with a well-defined WIT interface for one boundary in your system — preferably one that already has an informal protocol (a plugin API, an event handler contract, a data transformation step). Implement two components in Rust, validate composition and runtime behavior, then add a second language. Add complexity incrementally, and treat the toolchain version pin as a first-class CI concern from the start.

The Component Model is not yet the default choice for every WASM use case. It is the right choice when the type-checked boundary, the language independence, or the memory isolation is worth the toolchain investment. When those properties are what you actually need, nothing else provides them as cleanly.

Troubleshooting common errors


ERRORLIKELY CAUSEFIX
unsatisfied import: my-org:doc-pipeline/validatorcompose.yaml component name does not match the import name in the WIT worldAlign the name: field in compose.yaml with the exact import identifier in the WIT
type mismatch for import ... expected record ... found record (fields look identical)The same type is defined in two separate WIT interfaces instead of being shared via use types.{...}Move the shared type to the types interface; import it in both consumer interfaces
missing adapter: wasi_snapshot_preview1Component targets WASI Preview 1, but is being used without adaptationUse TinyGo 0.34+ with -target wasip2, or add the Preview 1 adapter explicitly
unsatisfied import: wasi:logging/loggingwasi:logging is imported in a WIT world, but not satisfied by any component or host linkerRemove from WIT for the minimal example; add host linker wiring before re-enabling
wit-bindgen generates different type names than expectedVersion mismatch between wasm-tools and wit-bindgenPin both to the same release; check the generated file names before writing the implementation
Python component is 20+ MBcomponentize-py embeds CPythonExpected: use Rust or TinyGo for size-sensitive deployments
Composition succeeds, but host instantiation fails with "type mismatch"A component was compiled against a different version of the WIT than the host's bindgen!Regenerate host bindings from the same WIT directory used to build all components

The Component Model is production-useful, but the tooling is less stable than protobuf/gRPC or containerized plugin protocols. Treat toolchain version pinning and generated-code review as architectural decisions, not setup details — they are the difference between a composition that works once and a pipeline you can maintain across tool releases.