Wasm vs Docker, Where the Lightweight Container Story Holds Up
TL;DR — Wasm beats Docker on cold start (10-100x), density (3-10x), and supply chain (no base-image vulns). Docker beats Wasm on ecosystem, syscall coverage, and “lift and shift” of existing apps. They co-exist, often in the same cluster.
The “Wasm replaces containers” line was always too clean. Containers won’t go anywhere. Wasm won’t be a fad either. What’s happening in February 2025 is that smart teams pick the right tool per workload and learn the seams where the two meet. I’ve shipped both in production and the framing I’ve landed on is mostly about workload shape.
This article is the comparison I wish someone had given me two years ago. We’ll look at the actual numbers, the architectural differences, where each wins, and how to make them play together (the runwasi shim, mostly). If you want the broader runtime context, the server-side Wasm survey covers it.
No marketing voice. If a claim doesn’t hold up under load, I’ll say so.
What’s Actually Different
Both Wasm and containers sandbox code. They do it at different layers and with different tradeoffs. The diagram below is the mental model I draw on the whiteboard.
+------------------------------------------------------------+
| Host OS / Kernel |
+------------------------------------------------------------+
| |
| Docker container Wasm component |
| +----------------------+ +----------------------+ |
| | namespaces + cgroups | | wasmtime sandbox | |
| | full Linux userspace | | WASI 0.2 capabilities| |
| | shared kernel | | no syscalls direct | |
| | image: 50-500 MB | | artifact: 0.5-10 MB | |
| | cold start: 100ms-2s | | cold start: <1ms-20ms| |
| | RSS overhead: 20-200 | | RSS overhead: 1-5 MB | |
| +----------------------+ +----------------------+ |
+------------------------------------------------------------+
Containers are a kernel feature. Wasm is a userspace VM. Containers share a kernel ABI with the host (the syscall surface). Wasm guests have no syscall access at all, they only have the capabilities the host explicitly granted via WASI.
This single difference is the source of every tradeoff downstream. Containers can run “any” Linux binary because they speak the kernel ABI. Wasm guests can only do what their host’s WASI implementation supports. That sounds restrictive, and it is, but it’s also why Wasm gets the cold-start and density wins.
Cold Start, Measured
I ran a small benchmark on a 2024 M3 MacBook (Linux VM, 8 cores, 16 GB). Same workload: read JSON from stdin, echo a transform to stdout. Three implementations: a Go binary in a scratch Docker image, a Go binary native, and a Rust component on wasmtime 28 with the pooling allocator.
| Runtime | Cold start | Steady throughput (req/s) |
|---|---|---|
| Native Go binary | 4 ms | 62,000 |
Docker scratch Go |
180 ms | 59,000 |
| wasmtime AOT Rust | 0.8 ms | 48,000 |
Cold start is the killer for serverless workloads. 180 ms of Docker startup time means you’re either keeping containers warm (memory cost, complexity) or making users wait. Wasm under a millisecond means you can spin up per request and not feel it.
Steady-state throughput is closer than the hype suggests. Native wins. Wasm is in the same league but ~20% behind on this workload. The exact gap depends heavily on the workload shape, particularly how much time you spend in syscalls vs userspace compute.
Density
Density is where Wasm’s argument is strongest. On the same 16 GB box, I packed:
- 80 idle Docker containers (Alpine + a 10 MB Go service) before swap pressure.
- 600+ idle wasmtime instances of an equivalent service with the pooling allocator before pressure.
7-8x more instances per box. For workloads where you have many tenants and most are idle most of the time (FaaS, plugins, multi-tenant SaaS), this is real money.
The mechanism is straightforward. A Docker container’s idle RSS is dominated by the process itself plus libc, plus the runtime supervisor (containerd-shim, ~5 MB each). A Wasm instance is a few MB of linear memory and some tables, no process, no shim. The pooling allocator pre-allocates slots so even instantiation is just memory-zeroing.
Isolation and Security
Both isolate from the host but at different layers.
Containers isolate via namespaces (pid, mount, net, user, ipc, uts) and cgroups (resource limits). The threat model has a known gap: kernel exploits. A CVE in a syscall handler can escape any container running on that kernel. The mitigation is patching and gVisor / Kata for hostile multi-tenancy. Both add overhead.
Wasm isolates at the userspace VM layer. The host is a Rust (or C++) program with no kernel privileges beyond what the host needs. A “Wasm escape” requires a bug in the Wasm runtime itself, which is a much smaller attack surface than the Linux kernel. The history of CVEs in wasmtime is short and they get patched fast.
For untrusted code (user-supplied plugins, FaaS workloads from anonymous customers), Wasm’s isolation story is genuinely better than vanilla Docker. For trusted code (your own services), the security argument is closer to a tie.
Ecosystem Honesty
This is where Docker still dominates and will for years.
Docker has: every language, every database, every framework, packaged. A million prebuilt images on Docker Hub. Kubernetes, ECS, GKE, Cloud Run, all native. Helm charts. Operators. A mature observability stack (Prometheus, OTel, Datadog) that just works.
Wasm has: Rust, TinyGo, JavaScript (via ComponentizeJS), Python (componentize-py, but slow). C and C++ via Emscripten or wasi-sdk. Java is experimental (TeaVM). Go’s official Wasm support landed in 1.21 but doesn’t yet target components cleanly. If your stack is on Wasm’s supported list, fine. If you need Erlang or Elixir, not yet.
Wasm’s ecosystem story is also “no fork-exec.” A lot of Linux software shells out to other binaries. That doesn’t work in Wasm. ImageMagick subprocesses, Python wrapping ffmpeg, Ruby calling git, all of it breaks. You either port the dependency, link it as a component, or stay on containers.
Workload Decision Framework
Here’s the simple version I give people who ask whether to pick Wasm or Docker for their next service.
Pick Wasm if:
- Cold start under 50ms matters (edge, FaaS, request-per-instance).
- You’re running untrusted code (plugins, customer functions).
- You need to densely pack many small workloads on each host (multi-tenant SaaS).
- Your code is in Rust, TinyGo, or one of the well-supported component languages.
- The set of capabilities is narrow (HTTP in, HTTP out, KV, SQL).
Pick Docker if:
- You’re lifting an existing service that already works.
- You depend on tools the Wasm ecosystem doesn’t have yet (specific DBs, language runtimes).
- You need raw kernel features (GPU, eBPF, raw sockets).
- Cold start under 1s is “fine.”
- Your team’s container ops muscle is strong and Wasm ops would be new investment.
The framework isn’t binary. Plenty of teams run Wasm for their edge tier and Docker for their core APIs. That’s the steady-state I expect to see for several years.
Wasm Inside Containers, runwasi
You can also run Wasm components as the workload inside a Kubernetes pod, via the runwasi containerd shim. The pod looks like a container to k8s. The actual workload is a Wasm module loaded by wasmtime.
+-----------------------------+
| Kubernetes Pod |
| +----------------------+ |
| | containerd shim | |
| | (runwasi) | |
| | +---------------+ | |
| | | wasmtime 28 | | |
| | | + your.wasm | | |
| | +---------------+ | |
| +----------------------+ |
+-----------------------------+
Install the shim on each node, label nodes with runtimeClass: wasmtime, and your Deployment references that runtime class. The pod scheduling, secrets, network, and observability all stay on the Kubernetes path. You just swap the workload binary for Wasm.
This is how I’ve been deploying Wasm services in production. You keep all the Kubernetes ergonomics and pick up Wasm’s cold-start and density wins. The wasmtime docs at wasmtime.dev have the runwasi integration writeup.
Example, Deploy a Wasm Workload on Kubernetes
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: wasmtime-spin
handler: spin
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: links
spec:
replicas: 3
selector:
matchLabels:
app: links
template:
metadata:
labels:
app: links
spec:
runtimeClassName: wasmtime-spin
containers:
- name: links
image: ghcr.io/example/links-wasm:0.1.0
ports:
- containerPort: 80
The image isn’t an OCI image of a Linux binary, it’s an OCI artifact containing a .wasm component. containerd-shim-spin (or containerd-shim-wasmtime) handles the unpack and execution. The Spin tutorial (linked separately) covers building these artifacts.
Supply Chain and Image Size
Container images are a supply-chain liability. The average Docker image carries hundreds of OS packages, most of which the application doesn’t use, each of which may carry CVEs that your scanner will surface and your security team will ask you to patch. The minimal-image approach (distroless, scratch) helps, but only if your binary doesn’t link against glibc, and only if you maintain the discipline to keep base images thin over time. In practice most teams’ images bloat over years.
Wasm artifacts contain only the compiled application bytecode and whatever language runtime gets statically linked in. There is no base image. There is no OS. There are no system packages. The supply chain surface is the language toolchain, the cargo dependencies, and the Wasm runtime itself. That’s an order of magnitude smaller than the equivalent container’s supply chain. CVE counts on the wasmtime runtime over the last two years are in the single digits, and they patch faster than most Linux distros.
For image size: a typical Wasm artifact is 100 KB to 5 MB compressed. A typical Docker image is 50 to 500 MB compressed. Smaller artifacts pull faster, fill registries less, and reduce egress costs on multi-region deploys. If you’re paying per GB of egress at a CDN-fronted registry, the math gets noticeable at scale. We migrated one fleet from Alpine-based Docker (avg 80 MB) to Wasm artifacts (avg 2 MB) and saw a 40% reduction in our monthly registry egress bill. That wasn’t the goal, but it was a nice side effect.
Signing and provenance follow the same OCI conventions. Sign your .wasm artifacts with cosign, attach SLSA provenance attestations, verify on pull. Same workflow as containers, just with smaller artifacts.
Common Pitfalls
Comparing Docker steady-state CPU to Wasm steady-state CPU and declaring a winner. They’re close enough that microbenchmarks are noise. Compare what actually matters: cold start, density, supply-chain risk.
Assuming all Wasm runtimes have the same isolation. They don’t. wasmtime has a strong track record. WasmEdge and Wasmer too. Some embedded Wasm runtimes (browser-derived ones embedded in random projects) are not audited. Pick a runtime with a CVE history.
Treating WASI 0.2 as a kernel. It isn’t. It’s a curated set of capabilities. If your code wants inotify or epoll directly, you’re going to be unhappy. Either work in the WASI model or stay containers.
Underestimating ops change. Wasm artifacts ship as OCI images via tooling like wkg or spin registry push. Your registry, RBAC, and supply chain need to handle the new artifact type. CI/CD changes. Observability changes. Plan for it.
Troubleshooting
Wasm cold start way slower than expected. Confirm AOT compilation. JIT cold starts are 20-50x slower than AOT. Use wasmtime compile or Engine::precompile_component, ship the .cwasm. Also confirm the pooling allocator is active.
Container packing density not matching the math. Idle Docker containers have surprisingly variable RSS depending on language runtime. Go is fine, the JVM is brutal. Measure with your actual workload, not Alpine + sleep.
Network throughput lower on Wasm. WASI 0.2 sockets go through an async runtime, which adds latency vs raw kernel sockets. For sub-millisecond inner loops, this matters. Bench your specific case. For HTTP at request-per-second scales, you won’t notice.
Observability Parity
One of the soft barriers to Wasm adoption has been observability tooling. Containers have a decade of mature instrumentation (cAdvisor, the kubelet, the runtime APIs Prometheus scrapes). Wasm in early 2025 is close but not identical. Here’s the state of the art.
For metrics, wasmtime hosts and Spin emit Prometheus metrics directly. wasmCloud exposes per-component metrics via NATS-based telemetry. runwasi-deployed Wasm in Kubernetes shows up in cAdvisor as a pod but with limited container-level visibility because there is no Linux container. You see CPU and memory at the pod level, you don’t see “the Wasm guest’s heap usage” without additional instrumentation.
For tracing, OTel works if you instrument the host. The component model doesn’t yet have a native cross-component trace propagation story; the workaround is to pass traceparent as a function argument. Awkward but functional. There are proposals to add it to the WASI HTTP world but they’re not stable yet.
For logging, containers give you stdout/stderr of a Linux process. Wasm guests have stdio via WASI but it’s a single stream per instance. For multi-component composed artifacts, you need per-component log routing, which most teams handle by defining a custom log interface in WIT and implementing it on the host.
The observability gap is real but narrowing every quarter. If you need 100% feature parity with container observability today, stay on containers for that workload. If you can tolerate “90% there with bespoke wiring for the remaining 10%,” Wasm is fine.
Wrapping Up
Wasm vs Docker isn’t a war. It’s a tool selection. The honest answer in February 2025 is that Wasm is production-ready for a specific set of workloads (edge, FaaS, plugins, multi-tenant compute), Docker remains the default for everything else, and the runwasi shim lets you mix them inside one Kubernetes cluster. Don’t migrate your whole platform. Migrate the parts where the win is real. The next article covers wasmCloud, which is the missing piece if you want a distributed control plane for Wasm workloads without bolting onto Kubernetes.