Docker changed how we ship software. Kubernetes changed how we run it. Server-side WebAssembly is quietly doing both at once — and most engineers are still sleeping on it.
The promise is real: WASM modules are smaller than container images, start in microseconds instead of seconds, enforce a hard capability boundary by default, and run identically across any CPU architecture. No more "works on my machine." No more 300 MB base images for a 4 MB binary.
The problem is that the ecosystem is a mess. You’ve got low-level runtimes (Wasmtime, WasmEdge), high-level platforms (Spin, WAGI, Wazero), and a standards process (WASI, the Component Model) that keeps moving the goalposts. Pick the wrong layer and you’ll be rewriting glue code in six months.
This article cuts through that. We’ll build the same HTTP service in each environment — Wasmtime, WasmEdge, and Spin — look at the actual DX, point out where each runtime will bite you, and give you a straight answer on what to use in production today.
The Contenders
Wasmtime (github.com/bytecodealliance/wasmtime) — the reference runtime from the Bytecode Alliance. It’s what the WASI spec is written against. Think of it like CPython: authoritative, but not always the most ergonomic thing to build on directly.
WasmEdge (github.com/WasmEdge/WasmEdge) — the CNCF-graduated runtime aimed squarely at cloud-native workloads. It has OCI integration, a plugin system, and first-class support for AI/ML inference via GGML and PyTorch backends. The Docker Desktop integration ships WasmEdge under the hood.
Spin (github.com/fermyon/spin) — Fermyon’s developer-facing platform that sits on top of Wasmtime. You write a handler, spin build, spin up. It handles HTTP routing, key-value stores, SQLite, Redis, and outbound HTTP for you. The closest thing WASM has to a batteries-included framework.
Before You Start: WASI Versions Still Matter
This is the biggest gotcha nobody mentions upfront.
WASI 0.1 (Preview 1) is the old socket-based API. WASI 0.2 introduced the Component Model — a proper type system for composing WASM modules with typed interfaces defined in WIT (WASM Interface Types). Most mature tooling has moved to 0.2, but ecosystem support is still uneven.
Check which WASI version your runtime targets before you write a single line of code. A module compiled for WASI 0.2 will not run on a runtime that only implements 0.1. Wasmtime ≥ 18 supports both. WasmEdge supports WASI 0.1 well and 0.2 partially as of mid-2026. Spin 3.x runs fully on the Component Model (WASI 0.2).
Setting Up the Toolchain
You’ll need the WASM-specific Rust target regardless of which runtime you use:
rustup target add wasm32-wasip2 # WASI 0.2 / Component Model
rustup target add wasm32-wasip1 # WASI 0.1 (legacy, still needed for WasmEdge)
For component builds, install cargo-component:
cargo install cargo-component
Round 1: Wasmtime — The Reference Runtime
Wasmtime is what you use when you need maximum spec compliance, embedding in another application (your Rust/Go/Python host), or fine-grained control over capabilities. You would not typically expose Wasmtime directly to users or deploy it as an HTTP server — that’s not its job.
Installing Wasmtime
curl https://wasmtime.dev/install.sh -sSf | bash
wasmtime --version
# wasmtime 30.0.0
A Minimal HTTP Service the Hard Way
Wasmtime doesn’t give you an HTTP framework. It gives you a runtime. To serve HTTP with raw Wasmtime you have two options: use the wasi:http interface (Component Model) or run a full binary that opens sockets itself (WASI 0.1). Let’s do it properly with WASI 0.2.
First, define the WIT interface. Create wit/world.wit:
package example:[email protected];
world handler {
// imports the WASI HTTP types
import wasi:http/[email protected];
// our component exports a request handler
export wasi:http/[email protected];
}
Your src/lib.rs:
use exports::wasi::http::incoming_handler::Guest;
use wasi::http::types::*;
// bindings generated by cargo-component from the WIT file
wit_bindgen::generate!({ world: "handler" });
struct HttpHandler;
impl Guest for HttpHandler {
fn handle(request: IncomingRequest, response_out: ResponseOutparam) {
let response = OutgoingResponse::new(Fields::new());
response.set_status_code(200).unwrap();
let body = response.body().unwrap();
let writer = body.write().unwrap();
writer
.blocking_write_and_flush(b"Hello from Wasmtime!\n")
.unwrap();
OutgoingBody::finish(body, None).unwrap();
ResponseOutparam::set(response_out, Ok(response));
}
}
export!(HttpHandler);
Build it:
cargo component build --release --target wasm32-wasip2
Run it (Wasmtime itself doesn’t have an HTTP listener — you need wasmtime serve which wraps the wasi:http host):
wasmtime serve \
--addr 127.0.0.1:8080 \
target/wasm32-wasip2/release/http_handler.wasm
curl https://cd-linux.club/
# Hello from Wasmtime!
Gotchas with Wasmtime
No persistent state between requests. Each invocation is stateless by default. If you need shared state across calls within an embedded context, you have to manage it explicitly in your host and pass it via capabilities.
wasmtime serve is not production-grade. It’s a development convenience. For real traffic, embed Wasmtime in a Rust host with tokio and manage the lifecycle yourself, or switch to a higher-level platform like Spin.
Fuel/epoch limiting is your safety valve. Malicious or buggy modules can spin in an infinite loop. Always configure epoch-based interruption when embedding:
let mut config = Config::new();
config.epoch_interruption(true);
let engine = Engine::new(&config)?;
// in a background thread:
loop {
std::thread::sleep(Duration::from_millis(10));
engine.increment_epoch();
}
Skipping this in multi-tenant scenarios is a real production mistake.
Round 2: WasmEdge — Cloud-Native First
WasmEdge is what you reach for when you want WASM inside a Kubernetes cluster, inside a Docker container (via the wasm runtime shim), or running AI inference workloads at the edge. It has a much richer extension ecosystem and better integration with container tooling than Wasmtime.
Installing WasmEdge
curl -sSf https://raw.githubusercontent.com/WasmEdge/WasmEdge/master/utils/install.sh | bash -s -- -v 0.14.1
source ~/.bashrc
wasmedge --version
HTTP with WasmEdge’s Hyper Integration
WasmEdge ships a Tokio-compatible async runtime for WASM and a hyper-based HTTP stack via the wasmedge_http_req or the full tokio_wasi crate.
Cargo.toml:
[dependencies]
wasmedge-bindgen = "0.4"
wasmedge_wasi_socket = "0.5"
# or use the hyper integration
hyper = { version = "0.14", features = ["http1", "server", "tcp"] }
tokio_wasi = { version = "1", features = ["io-util", "fs", "net", "time", "rt"] }
src/main.rs:
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Request, Response, Server};
use std::convert::Infallible;
use std::net::SocketAddr;
async fn handle(_req: Request<Body>) -> Result<Response<Body>, Infallible> {
Ok(Response::new(Body::from("Hello from WasmEdge!\n")))
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
let make_svc = make_service_fn(|_conn| async {
Ok::<_, Infallible>(service_fn(handle))
});
let server = Server::bind(&addr).serve(make_svc);
server.await.unwrap();
}
Build and run:
cargo build --target wasm32-wasip1 --release
wasmedge --net \
target/wasm32-wasip1/release/wasmedge_http.wasm
Running WasmEdge Modules as OCI Containers
This is where WasmEdge gets genuinely interesting for ops teams. You can package a .wasm file as an OCI image and run it with containerd + the WasmEdge shim — no Dockerfile, no base image, no OS layer at all.
# Build the OCI image with wasm-to-oci or docker buildx
docker buildx build \
--platform wasi/wasm \
--provenance=false \
-t myrepo/wasmedge-app:latest \
-f Dockerfile.wasm .
Dockerfile.wasm:
FROM scratch
COPY target/wasm32-wasip1/release/wasmedge_http.wasm /app.wasm
ENTRYPOINT ["/app.wasm"]
That image is typically 2–5 MB. A comparable Alpine + binary image is 20–40 MB. The startup delta in Kubernetes is measured in milliseconds vs seconds.
The containerd shim (containerd-shim-wasmedge-v1) intercepts the wasi/wasm platform tag and routes execution through WasmEdge instead of runc.
Gotchas with WasmEdge
The plugin system is powerful but fragile across versions. WasmEdge’s GGML plugin for LLM inference is great, but plugin ABI compatibility between WasmEdge versions is not guaranteed. Pin your plugin version in CI. Strictly.
WASI 0.2 / Component Model support is partial. As of 0.14.x, WasmEdge’s Component Model support is still catching up to Wasmtime. If you need WIT-typed component composition, you’re better off on Wasmtime or Spin. WasmEdge’s strength is the WASI 0.1 + networking + AI plugins stack.
Don’t use --net in untrusted multi-tenant scenarios without capability restrictions. --net grants full socket access. Use WasmEdge’s plugin capability flags to restrict what each module can actually reach.
Round 3: Spin — The Platform That Gets Out of Your Way
Spin is what you use when you want to ship something. It handles all the runtime plumbing so you can write business logic.
The mental model: Spin is to Wasmtime what Rails is to Ruby. It makes opinionated choices (HTTP routing, component triggers, managed storage) so you don’t have to reinvent the host every time.
Installing Spin
curl -fsSL https://developer.fermyon.com/downloads/install.sh | bash
sudo mv spin /usr/local/bin/
spin --version
# spin 3.4.0
A Real HTTP Service in 5 Minutes
spin new -t http-rust my-api
cd my-api
Spin scaffolds the whole project. spin.toml:
spin_manifest_version = 2
[application]
name = "my-api"
version = "0.1.0"
[[trigger.http]]
route = "/..."
component = "my-api"
[component.my-api]
source = "target/wasm32-wasip1/release/my_api.wasm"
allowed_outbound_hosts = ["https://api.example.com"]
[component.my-api.build]
command = "cargo build --target wasm32-wasip1 --release"
watch = ["src/**/*.rs", "Cargo.toml"]
src/lib.rs (the generated scaffold is already idiomatic):
use spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_component;
#[http_component]
fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
println!("Handling request to {:?}", req.header("spin-path-info"));
Ok(Response::builder()
.status(200)
.header("content-type", "application/json")
.body(r#"{"status":"ok","runtime":"spin"}"#)
.build())
}
Build and run:
spin build
spin up
# Serving http://127.0.0.1:3000
That’s it. No socket code. No capability flags. No WIT boilerplate.
Adding a Key-Value Store
Spin’s managed storage is where it really pulls away from raw runtimes. Add this to spin.toml:
[component.my-api]
# ...
key_value_stores = ["default"]
And in your handler:
use spin_sdk::key_value::Store;
#[http_component]
fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
let store = Store::open_default()?;
// Increment a visit counter
let count: u64 = store
.get_json("visits")?
.unwrap_or(0u64)
+ 1;
store.set_json("visits", &count)?;
Ok(Response::builder()
.status(200)
.body(format!("Visit #{count}"))
.build())
}
In development, spin up backs this with an in-memory store. In production on Fermyon Cloud or self-hosted Spin Operator (the Kubernetes CRD), it maps to Redis or a managed backend. Same code, different backend — the abstraction holds.
Deploying Spin to Kubernetes
Spin ships a Kubernetes operator (SpinApp CRD) that works with the Spin shim for containerd:
apiVersion: core.spinoperator.dev/v1alpha1
kind: SpinApp
metadata:
name: my-api
spec:
image: "ghcr.io/myorg/my-api:latest"
replicas: 3
executor: containerd-shim-spin
resources:
limits:
cpu: "100m"
memory: "64Mi" # these are real numbers, not aspirational
Apply it: kubectl apply -f spinapp.yaml. The operator handles scheduling, the shim handles execution. Scale to zero works out of the box because cold starts are ~5ms.
Gotchas with Spin
Vendor lock-in on the storage abstractions. Spin’s key-value, SQLite, and Redis interfaces are ergonomic but Fermyon-defined. The underlying WASI proposals for these (key-value, SQL) are still draft. If you need to move off Spin later, you’ll rewrite the storage layer.
Outbound HTTP requires explicit allow-listing. allowed_outbound_hosts in spin.toml is not optional — any outbound call to a host not listed will fail at runtime with a trap, not a nice error. This is a security feature, not a bug, but it catches developers every time in staging.
Template ecosystem is still thin. Spin’s built-in templates cover Rust, TypeScript, Python, and Go. If your team writes Zig or C#, you’re writing the WIT bindings yourself.
Head-to-Head: The Honest Comparison
| Dimension | Wasmtime | WasmEdge | Spin |
|---|---|---|---|
| WASI 0.2 / Component Model | Full | Partial | Full |
| HTTP DX | Manual / embedded | Good (hyper) | Excellent |
| OCI / Kubernetes | Via shim | Native (CNCF) | Via Spin Operator |
| Storage abstractions | DIY | DIY | Built-in |
| AI/ML inference | Plugin-based | Excellent (GGML) | No |
| Multi-tenant safety | Excellent (epochs) | Good (plugins) | Good (default caps) |
| Cold start | ~1ms | ~2ms | ~5ms |
| Production maturity | High (embedding) | High (edge/AI) | Medium-high |
| Lock-in risk | Low | Low | Medium |
Production-Ready Decisions
Use Wasmtime when you’re embedding a WASM runtime into your own application — a plugin system, a rules engine, a sandboxed user-code executor. It’s the most stable embedding API and the canonical spec target.
Use WasmEdge when you need WASM in Kubernetes at the container level, or when you’re running AI inference at the edge. The CNCF graduation matters for enterprise procurement. The GGML plugin is genuinely useful if you’re running local models.
Use Spin when you’re building HTTP services or APIs and want to ship fast. The DX is the best in class. Accept the Fermyon abstraction layer consciously — know what you’re trading away. For greenfield microservices where the team is small and iteration speed matters, Spin wins.
Do not mix runtimes on the same host without explicit version pinning. WASI ABI compatibility between runtimes is not guaranteed at the binary level. A .wasm that works on Wasmtime 30 and WasmEdge 0.14 might break on either if you upgrade one.
One More Gotcha: Debugging Is Still Painful
WASM stack traces are improving but still worse than native. You’ll get trap addresses, not function names, unless you build with DWARF info (-C debuginfo=2) and your runtime supports DWARF unwinding (Wasmtime does; WasmEdge partially; Spin inherits Wasmtime).
For WasmEdge, the wasmedge CLI has a --stats flag that gives you execution statistics. For Spin, spin up --log-dir ./logs captures per-component stdout/stderr. Neither gives you the kind of profiler tooling you’d get with a native Rust binary. This will improve; it’s not there yet.
The Bottom Line
Server-side WebAssembly is past the "interesting prototype" phase. The runtimes are stable, the tooling is workable, and the Kubernetes integration is real. What’s not there yet is a single obvious answer.
The Component Model (WASI 0.2) is the right abstraction layer long-term — portable, typed, composable. Build toward it. That means Wasmtime for embedding, Spin for application development, and WasmEdge when you specifically need the Kubernetes shim integration or AI inference capabilities that the others don’t have.
If you’re starting a new service today: use Spin, build with cargo-component, deploy to K8s with the Spin Operator. That’s the highest-leverage path in 2026. If you outgrow Spin’s abstractions, the knowledge transfers directly to Wasmtime — Spin is Wasmtime underneath, after all.
The runtimes are not competing on correctness anymore. They’re competing on the experience of building things on top of them. And on that metric, the gap between a raw runtime and a platform like Spin is measured in days of development time per feature. Choose accordingly.