Wasm Component Model & WIT Bindings

Core WebAssembly has four value types, all numeric. Everything richer — a string, a record, a list, a result type — is a convention layered on top by whatever generated your bindings, and two modules built by different toolchains have no way to call each other because they made different conventions. The component model is the standard answer to that: a typed interface layer, described in an interface definition language called WIT, with a specified ABI for turning those types into the numbers the core instruction set understands.

For a full-stack engineer the immediate relevance is twofold. It is where WASI is heading, so a wasm32-wasip2 build is a component whether or not you engage with the model directly. And it offers an alternative to language-specific binding generators: instead of wasm-bindgen for Rust and something else for another language, one interface description generates bindings for every side.

Prerequisites

  • [ ] cargo-component or wasm-tools for producing and inspecting components
  • [ ] jco if you intend to run components in JavaScript (npx jco --version)
  • [ ] Node 20+, or a browser, depending on where the generated bindings will run
  • [ ] Familiarity with the numeric boundary this layer sits on top of — the (ptr, len) ABI is what it is hiding

What a component adds

A core module is a flat list of imports and exports typed with i32, i64, f32 and f64. A component wraps one or more core modules and adds a typed interface layer: named interfaces with versioned identifiers, containing functions whose parameters and results are described with real types — string, list<u8>, record, variant, result, and resource handles.

Two layers, not a replacement Inside a component the core module still exports numeric functions and owns linear memory. Above it sits a typed interface described in WIT, and the canonical ABI defines mechanically how each typed value is lowered into numbers and lifted back out. the typed interface — described in WIT resize: func(image: list<u8>, width: u32, height: u32) -> result<list<u8>, string> the canonical ABI specified, mechanical lowering of each type into numbers and back the core module — unchanged resize: func(i32, i32, i32, i32) -> i32 · linear memory · realloc

The important consequence is that the numeric boundary has not gone away — it has been standardised. The lowering that wasm-bindgen performs with generated JavaScript, the canonical ABI performs according to a specification that every toolchain implements the same way. That is what makes a component produced by one language callable from another without either side knowing what produced it.

Prerequisites checklist for a first component

  • [ ] A WIT file describing the interface you want to expose
  • [ ] A guest implementation in a language with component tooling (Rust is the most mature)
  • [ ] A host: jco for JavaScript, wasmtime for the command line
  • [ ] wasm-tools component wit to read back what a built artifact actually declares

Step-by-step workflow

  1. Describe the interface. A world names what a component imports and exports:

    package example:imaging@0.1.0;
    
    interface resize {
      resize: func(image: list, width: u32, height: u32) -> result, string>;
    }
    
    world imaging {
      export resize;
    }
  2. Generate the guest bindings and implement them:

    cargo component new --lib imaging
    # wit/world.wit as above
    cargo component build --release
  3. Inspect what you produced:

    wasm-tools component wit target/wasm32-wasip1/release/imaging.wasm
  4. Generate host bindings for JavaScript:

    npx jco transpile imaging.wasm -o dist/imaging
  5. Call it as an ordinary module:

    import { resize } from "./dist/imaging/imaging.js";
    const out = resize(new Uint8Array(pixels), 1920, 1080);
  6. Compose, if you have more than one. A component that imports an interface another exports can be linked into a single artifact ahead of time, with no host mediation at run time.

Optimization & tradeoffs

The typed layer is not free. Every call across a component boundary lifts and lowers its arguments according to the canonical ABI, which for a list<u8> means an allocation in the callee’s memory and a copy into it. That is the same work wasm-bindgen does — the cost has been standardised, not removed — and it responds to the same discipline: coarse-grained calls with buffers, not per-item calls with records.

Tooling maturity is the other half of the trade. Rust’s component support is production-usable; other languages vary from good to experimental. Host support is similar: wasmtime and jco are solid, while browser support goes through jco’s transpilation to core modules plus JavaScript rather than being native. None of this is a reason to avoid the model, but it does mean a component-based project takes on more toolchain risk than a wasm-bindgen one today.

Where each approach is stronger today wasm-bindgen offers the most mature tooling and the best browser ergonomics for Rust specifically. The component model offers language-neutral typed interfaces and composition, at the cost of newer tooling and an extra transpilation step for browsers. wasm-bindgen Rust ↔ JavaScript, deeply integrated web-sys covers the whole browser API mature, widely deployed, well documented the pragmatic choice for a browser module today but the ABI is its own, not a standard component model language-neutral typed interfaces components compose without a host the direction WASI is standardising on the right choice for polyglot or server-side work browser use goes through a transpile step

Gotchas & failure modes

A core-module tool rejects your artifact. Components are a different container. wasm-objdump and similar will refuse them; use wasm-tools component for inspection.

jco transpile output does not run in the browser. Check the target flags and that the generated files are all being served — transpilation emits several, including a core module alongside the JavaScript.

A WIT change breaks the guest silently. Regenerate the bindings; a stale generated file will compile against the old interface and mismatch at run time.

Performance regressed after moving from a hand-written ABI. The canonical ABI copies where your hand-written code may have aliased. For bulk data, keep a raw path alongside the typed one.

Versions collide. WIT packages are versioned, and two dependencies wanting different versions of an interface is a real situation. Pin deliberately.

A world is a two-sided contract Everything a component exports is what a host or another component may call. Everything it imports is what the host must supply before it can run. Together they are the world, and a host that satisfies the imports can run the component without knowing anything else about it. exports — what it offers typed functions any host can call the reason the component exists imports — what it needs interfaces the host must satisfy first the capability surface to review Reading a stranger's component starts with its world: the imports tell you what it can reach, exactly as an import object does for a core module.

Resources: state that outlives a call

The type that most changes how an interface is designed is resource. A resource is an opaque handle to something the implementing side owns — an open file, a decoder, a database connection, a parsed document — with methods attached and a defined lifetime. It is the component model’s answer to the problem every Wasm boundary eventually hits: how do you keep state on one side without copying it across on every call?

interface decoding {
  resource decoder {
    constructor(format: string);
    push: func(chunk: list) -> result<_, string>;
    take-frame: func() -> option>;
  }
}

Three properties make this worth reaching for. The handle itself lowers to a single i32, so passing it costs nothing regardless of how much state sits behind it. Ownership is explicit — the handle is owned by whoever holds it, and dropping it destroys the underlying resource — so the “who frees this” question that dominates hand-written ABIs is answered by the type system. And methods are namespaced under the resource, which produces a generated API that reads naturally on both sides rather than a flat list of functions taking a context parameter.

The pattern it enables is the one that keeps component interfaces fast: create a resource once, feed it small pieces, and read small results back. The large data never crosses the boundary at all, because it lives inside the component that owns the resource. Compare that with an interface where every call takes and returns a list<u8> and the difference is between a copy per call and no copies at all.

Borrowing is the subtlety worth knowing. A function can take a resource by reference — borrow<decoder> — which lets it use the resource without taking ownership, so the caller keeps it afterwards. Getting that distinction right in the WIT is what makes the generated code natural in both Rust, where it maps onto ownership and borrowing directly, and JavaScript, where it maps onto an object with a Symbol.dispose method.

The one cost is that resources cannot cross between two components that do not share the definition — a handle is meaningful only to its owner. That is the same constraint as a pointer into linear memory, expressed in a form the type system can check, and it is the reason an interface passing resources between three components needs the resource type to come from a shared package rather than from either end.

Verification

Read back the interface from the artifact rather than trusting the source WIT — the artifact is what consumers see:

wasm-tools component wit imaging.wasm
wasm-tools validate imaging.wasm && echo 'valid component'
npx jco transpile imaging.wasm -o dist/ && node -e "import('./dist/imaging.js').then(m => console.log(Object.keys(m)))"

Adopting it incrementally

The component model is easy to present as an all-or-nothing migration and rarely needs to be one. Four adoption levels, in increasing order of commitment, cover most situations — and stopping at any of them is a reasonable outcome.

Level one: read components other people produce. A wasm32-wasip2 build is already a component, so anyone targeting recent WASI is producing one whether they engaged with the model or not. Being able to run wasm-tools component wit on an artifact and read its world is useful on its own — it tells you what a third-party module can reach, which is the same audit an import list gives you for a core module.

Level two: describe an interface you already have. Writing the WIT for an existing boundary, even without building a component, is a clarifying exercise: it forces the ownership rules and the exact types into a file both sides can be checked against. Several teams stop here and keep their existing binding generator, using the WIT purely as documentation that does not drift.

Level three: ship a component for one consumer. A server-side or CLI consumer is the low-risk place to start, because the tooling is most mature there and the browser’s transpilation step is not involved. If the component works for that consumer and the interface holds up, extending it is straightforward.

Level four: browser delivery through jco. This is the level with the most moving parts — a transpilation stage, generated JavaScript, a bundler that has to handle both — and the one where wasm-bindgen remains a legitimate alternative for Rust-only projects. Reaching it should be a decision about portability, not a default.

The order matters because each level produces value on its own and none of them require the next. A team that reaches level two and stays there has better-documented boundaries and lost nothing; a team that jumps to level four for a browser-only Rust project has taken on tooling risk for portability it is not using.

The one thing worth doing regardless of level is structuring the code so the computation is separable from the binding layer. That is good practice independently, and it is what makes any of these levels a day’s work rather than a rewrite — the same structure described in component model vs wasm-bindgen.

In this guide

Frequently Asked Questions

Do I need the component model for browser work? Not today. wasm-bindgen is more mature for Rust in the browser, and components reach the browser through a transpilation step rather than natively. The model is compelling when you need language-neutral interfaces or composition.

Is a component slower than a core module? The computation is identical. The boundary costs what the canonical ABI’s lifting and lowering costs, which is comparable to generated glue — proportional to the data crossing, not to the call count alone.

Can a component import from JavaScript? Yes: interfaces its world imports become imports the host supplies, and jco generates the shape for you. It is the same idea as an import object, typed.

What happened to interface types? That earlier proposal evolved into the component model and the canonical ABI. Documentation referring to interface types is describing this work under its old name.

Worlds, and what a host has to provide

A world is the unit a component is built against, and understanding it is what makes the rest of the model concrete. It is simply the pair of lists: what the component exports, and what it expects the host to import for it.

That pairing has a practical consequence worth stating directly: a component with imports cannot run until something satisfies them. A host embedding the component supplies implementations; a composition step can supply them from another component; and a component with no imports at all runs anywhere, which is why the simplest components are also the most portable.

world imaging {
  import wasi:logging/logging@0.1.0;   // the host must provide this
  export resize;                        // the host may call this
}

Reading that world tells a host author exactly what to prepare, and reading it tells a security reviewer exactly what the component can reach — the same audit an import list gives for a core module, but typed and named rather than a flat list of numeric functions.

Worlds are also where the standard interfaces live. wasi:cli describes a command-line environment; wasi:http describes a request handler. Targeting a standard world means any host implementing it can run your component without bespoke integration, which is the property that makes the model interesting for edge and plugin platforms — the host and the guest are written by people who never spoke to each other, and they interoperate because both were built against the same published world.

The design advice that follows is to keep worlds small and specific. A world importing six interfaces requires all six from every host; a world importing one runs in far more places. Where a capability is genuinely optional, expressing it as a separate world rather than an optional import keeps the contract honest — a host either satisfies a world or it does not, and there is no partial state to reason about.

Is the component model only relevant with WASI? No, though the two are closely associated because WASI’s newer interfaces are defined as component interfaces. A component can describe any interface at all — an image codec, a policy evaluator, a template renderer — and needs nothing from WASI unless it wants files or clocks. The confusion arises because the most visible components today are WASI-targeting builds, which makes the model look like a server-side concern when it is really an interface concern.

How stable is the specification? The core of it — worlds, the type vocabulary, the canonical ABI — has been stable enough that tooling built against it keeps working across releases, and the WASI interfaces layered on top are versioned independently. What still moves is the periphery: newer type constructors, async support, and the details of how some hosts expose composition. For a project adopting it today the practical risk is tooling maturity rather than the specification changing underneath you, which is why pinning tool versions matters more here than it does for a wasm-bindgen project.

What does a component cost compared with a core module? The binary is slightly larger, because the container carries the interface description and the adapter code that implements the ABI around the core module inside it. For a small module that overhead is noticeable in relative terms and trivial in absolute ones — a few kilobytes — and for a large module it disappears into the noise. Execution speed is unchanged, since the core module inside is the same code the compiler would have produced anyway.

Can I inspect a component without special tooling? Not usefully. Core-module tools reject the container, so wasm-tools or an equivalent is required to read the world, the inner modules and the adapters. That is worth knowing before adopting the format in an environment where the deployment pipeline inspects artifacts — a size-checking or scanning step built around wasm-objdump will need updating alongside the build.

← Back to JS/Wasm Interop & Memory Management