The Docker Tax
When you type docker run, something deceptively simple happens: your application starts. What isn't visible is the machinery that makes it possible. Between your code and the CPU sit eleven distinct layers, each solving a real problem that Linux doesn't solve natively. Let's walk through every one of them.
Docker was built to answer a specific question: how do you ship software that works the same on every Linux machine? The answer was to package the entire userspace -- the OS libraries, the language runtime, the application code -- into a portable image, and then use kernel features to isolate each image from the host and from each other. That was a breakthrough in 2013. The problem is that each of those isolation layers has a cost, and the costs compound.
Here's what actually runs when you start a container:
- APP Your application code -- the thing you actually wrote. Typically 2-50 MB of compiled binaries or interpreted scripts. This is the only layer that does useful work. 2-50 MB
- LANG Language runtime. Node.js, the Go toolchain, Python with pip, the JVM -- whatever your code needs to execute. A minimal Node.js base is 50 MB. A JVM is 200-800 MB. These get baked into every image. 50-800 MB
- OS Container OS. Ubuntu, Debian, Alpine -- a full (or slim) Linux userspace. This provides libc, coreutils, package managers, and the filesystem hierarchy your runtime expects. Alpine shaves this down to ~5 MB, but most production images use larger bases. 5-200 MB
- OCI Image layers + OverlayFS. Docker images are stacked filesystem diffs. OverlayFS merges these layers into a single view at runtime, using copy-on-write semantics. Every write creates a new layer entry. This is elegant but adds I/O latency and storage overhead. copy-on-write
- RT containerd + runc. The container runtime. containerd manages the lifecycle (create, start, stop, delete). runc actually creates the container by calling into the kernel. Both are long-running daemons that consume memory on every node. ~80 MB
- NS Linux namespaces. Six separate namespace types -- pid, net, mnt, uts, ipc, user -- each giving the container its own isolated view of process IDs, network interfaces, mount points, hostname, IPC, and user tables. Namespace creation is a kernel operation with measurable latency. per container
- CG cgroups v2. Resource limits for CPU, memory, and I/O bandwidth. The kernel tracks accounting data for every container in the cgroup hierarchy. At high container counts, the bookkeeping itself becomes a bottleneck. per container
- SEC seccomp + AppArmor/SELinux. Security filters that restrict which system calls the container can make. The default Docker seccomp profile blocks ~60 of the 300+ available syscalls. This is enforcement after access is already granted -- the container runs as a Linux process with access to everything, then filters are applied to take things away. 300+ syscalls
- NET veth pairs + bridge + iptables/CNI. Each container gets a virtual ethernet device, paired to the host's network bridge. Traffic routing is handled by iptables rules (or nftables, or eBPF in newer setups). In Kubernetes, the CNI plugin adds another abstraction layer on top. per container
- K8S kubelet + kube-proxy + CSI + CRI. On every Kubernetes node: kubelet (the agent), kube-proxy (service routing), the Container Storage Interface, and the Container Runtime Interface. This is ~200 MB of overhead before a single workload runs. ~200 MB
- HOST Linux kernel. Required. Everything above depends on Linux-specific kernel features. Containers are not portable across operating systems -- they are a Linux abstraction. required
To be clear: every one of these layers exists for a reason. Docker solved real, painful problems in software distribution and deployment. The language runtime exists because your code needs it. The container OS exists because your runtime expects POSIX. The namespaces exist because Linux processes can see each other by default. Seccomp exists because system calls are powerful and dangerous.
The question isn't whether these layers are well-engineered. They are. The question is whether you can skip them entirely by starting from a different set of assumptions.
The Stack Comparison
This is the same diagram from our architecture page, shown here for reference. The left column is what Docker/Kubernetes actually deploys. The right column is what WarpGrid deploys. The struck-through layers on the right don't exist in the Wasm execution model -- they aren't optimized or hidden, they're structurally absent.
Why WebAssembly Doesn't Need Containers
Docker's isolation model starts with a Linux process that has full access to the kernel, then subtracts capabilities. Namespaces hide the host's process table and network. Seccomp blocks dangerous syscalls. AppArmor restricts filesystem paths. Each layer is a filter that tries to prevent the process from doing something it could otherwise do. This is subtractive isolation -- you start with everything and take things away.
WebAssembly works in the opposite direction. A Wasm module executes inside a linear memory sandbox -- a contiguous byte array that the module can read and write, but that is the entire extent of its world. There are no file descriptors. There is no network socket API. There are no system calls. There is no process table, no environment variables, no filesystem, and no way to execute arbitrary code. The module literally cannot express these operations in its instruction set.
If a Wasm module needs to talk to the network or read a file, the host must explicitly provide that capability as a typed function import. This is the capability-based security model: the default is deny-all, and every permission is an opt-in decision by the host, declared in the deployment manifest. You don't filter out what's dangerous -- you grant only what's needed.
This distinction matters because it changes where the security boundary lives. In Docker, the boundary is the kernel -- if you escape the namespace, you own the host. In Wasm, the boundary is the bytecode format itself. There is nothing to escape from because there is nothing to escape to. The module has no concept of "the host" beyond the functions it was given. Isolation isn't enforced after the fact by a security policy. It's a structural property of the execution model.
The Numbers
Here's what this looks like in practice. These are real measurements from WarpGrid running on Hetzner bare metal, compared to the same workloads in Docker containers:
| Metric | Docker | WarpGrid |
|---|---|---|
| Cold start | 200-500ms | 0.3ms (Rust) |
| Memory per instance | 50+ MB | 2 MB |
| Instances per GB | ~20 | ~500 |
| Deployment artifact | 50-800 MB | 42 KB - 10 MB |
| Node overhead | 300+ MB (kubelet, containerd, kube-proxy) | 25 MB (warpd) |
The density difference -- 500 instances per GB vs 20 -- isn't a benchmark trick. It's the natural consequence of removing six layers of per-instance overhead. When you don't need a container OS, a layered filesystem, namespace bookkeeping, veth pairs, and cgroup accounting for each workload, the memory that those layers consumed becomes available for actual work.
For full methodology and reproducible benchmarks, see /benchmarks.
When Containers Still Win
We would be doing you a disservice if we pretended this is a universal solution. There are workloads and situations where Docker and Kubernetes are genuinely the better choice:
- JVM languages (Java, Kotlin, Scala). The JVM doesn't compile to Wasm today in any production-ready way. If your backend is Spring Boot, you're staying with containers for the foreseeable future.
- Existing Docker images you can't recompile. If you have a third-party binary or a vendor-provided image, you need a container runtime to run it. WarpGrid runs Wasm components, not OCI images.
- GPU workloads. Machine learning inference, video transcoding, anything that needs direct GPU access. Wasm has no GPU abstraction yet. Use containers (or bare metal).
- The Kubernetes ecosystem. Service meshes (Istio, Linkerd), operators, Prometheus, the entire CNCF landscape. If your organization has invested deeply in this tooling and it's working, the switching cost may not be worth the density gains.
- Windows containers. WarpGrid requires Linux. If you're running Windows Server workloads, containers are your path.
- Team familiarity. Kubernetes has a massive talent pool, extensive documentation, and a decade of battle-tested patterns. If your team already knows K8s and doesn't want to learn a new orchestrator, that's a legitimate reason to stay.
The honest assessment is: if you're writing Rust, Go, TypeScript, or Python services and deploying to Linux bare metal or VPS instances, WarpGrid can give you 10-100x better density and sub-millisecond cold starts. If you're running Java monoliths on managed Kubernetes, you're probably fine where you are.
Try It Yourself
The fastest way to see the difference is to run it. This takes about 60 seconds on any Linux or macOS machine:
$ curl -fsSL https://warpgrid.dev/install.sh | sh
# Start a local cluster
$ warpd standalone --port 8443
Cluster ready on :8443
# Deploy the hello-world example
$ warp deploy examples/hello.wasm --min 1
Deployed: hello (1 instance, 0.8ms cold start)
# Test it
$ curl http://localhost:8443/r/hello/health
{"status":"ok"}
One binary. No containers. No Kubernetes.
WarpGrid is open source under Apache 2.0 and free during beta.