DEV Community

Ryo Tanaka
Ryo Tanaka

Posted on

I benchmarked boxr vs podman on container startup. Here are the honest numbers.

TL;DR: On the same VM, starting a minimal cached container 20 times each: boxr 0.1.44 median 134 ms, podman 4.9.3 median 217 ms. boxr was ~38% faster on median startup. The full methodology and every caveat are below — please read them before quoting these numbers, because the caveats matter more than the headline.

What I measured

The simplest meaningful container operation: start a container from an already-pulled image and exit immediately.

  • boxr: boxr run --rootless --rm --network none library/alpine:latest true
  • podman: podman run --rm --network none docker.io/library/alpine:latest true

Same image bytes on both sides (alpine, identical config digest sha256:320994c3…). Wall-clock time measured with date +%s%N around the full CLI invocation — that's the cost a user actually feels, including CLI startup. Five cold runs per engine, then 20 warm runs each, alternating boxr/podman per iteration so any drift in machine load hits both equally. Zero failed runs out of 50, and I kept every data point — no outliers removed.

Warm results (n=20 each):

median mean min max p95
boxr 0.1.44 134 ms 150 ms 107 ms 262 ms 207 ms
podman 4.9.3 217 ms 225 ms 181 ms 310 ms 288 ms

Cold runs (first 5 invocations, image already pulled): boxr median 128 ms, podman median 212 ms — same story.

Environment: 2 vCPU (AMD EPYC 9D64), 7.7 GiB RAM, Ubuntu 24.04.5, kernel 7.0.0-38-generic. boxr was the prebuilt v0.1.44 Linux x86_64 release binary (checksum-verified). Podman came from Ubuntu's packages with crun 1.14.1.

The caveats (please don't skip these)

This is not a "rootless podman" measurement. I wanted both engines rootless, but this VM is itself a user-namespaced container with restrictions that made unprivileged user namespaces unusable (unprivileged uid_map writes fail, and setuid helpers can't elevate under the inherited no-new-privs flag — I verified this several ways before giving up). So both engines ran as root. boxr ran in its --rootless mode; podman ran rootful, without a container user namespace. If anything, that flatters podman: it skipped the user-namespace setup that boxr performed.

Networking was disabled on both sides. Podman's default netavark networking physically cannot run in this environment (setns: Operation not permitted), so podman required --network none. I gave boxr --network none too, for symmetry — its default auto mode sets up pasta networking and I measured that at roughly +45 ms. So: this compares engine + runtime startup, not network-stack setup.

Podman needed environment workarounds. Default podman storage chose fuse-overlayfs (no /dev/fuse here), so I pointed its graphroot at tmpfs, which lets it use native overlay with idmapped mounts — that likely helped podman, not hurt it. I also used --cgroup-manager=cgroupfs because there's no D-Bus here.

One VM, one image, one workload. n=20 is enough to see the gap is real (the distributions barely overlap — boxr's max warm run was 262 ms, podman's min was 181 ms), but it's still one machine and true is the most trivial workload possible. Real workloads with volume mounts, port publishing, and actual processes will look different.

A boxr quirk I had to work around: boxr doesn't normalize the docker.io/ registry prefix, so docker.io/library/alpine:latest misses its image cache and re-pulls + re-extracts on every run (~1.7 s penalty each time). I used the library/alpine:latest spelling boxr's cache recognizes. Same image, but worth knowing — I've written it up as a bug report for the maintainer to review.

What I think the numbers mean (speculation — labeled as such)

Speculation starts here. The ~80 ms gap is consistent with architectural differences rather than tuning: podman is a Go CLI that shells out through conmon to crun with a fair amount of configuration plumbing per invocation, while boxr is a single Rust binary with a shorter path from CLI to runtime. I'd guess CLI startup + per-run setup dominates at this scale, not the OCI runtime itself (both ultimately drive containers the same way).

What would change the picture: a heavier workload would shrink the relative gap (fixed startup cost amortized over real work); podman's daemonless-but-heavier CLI is a per-invocation tax, so short-lived containers are exactly where boxr's design should win and long-running ones where it matters least. I haven't measured any of that — it's inference, not data.

Where startup latency actually matters (and where it doesn't)

For long-running services, 80 ms is noise — nobody picks a container engine over a tenth of a second at deploy time. Startup latency matters in a narrower but real set of cases: CI jobs that spin up one-shot containers per test step, --rm helper containers in scripts, local dev loops where you restart a container dozens of times a day, and anything resembling scale-to-zero. If your containers live for hours, stop reading and go tune something else. If you launch hundreds of short-lived ones a day, per-invocation overhead is one of the few engine differences you'll actually feel.

What I'd measure next

This benchmark answered one narrow question. The honest follow-ups, in order of how much they'd change my confidence:

  1. As a non-root user. The single biggest gap in this data. True rootless podman (user namespaces, pasta/slirp networking, fuse-overlayfs) has a meaningfully different startup path than the rootful podman I could run here. If someone reruns this on bare metal as an unprivileged user, that's the comparison I actually wanted.
  2. A real workload. true measures engine overhead and nothing else. Next step up: a container that starts a web server and serves one request, timed to first byte. That folds in network setup — where the engines genuinely differ — instead of excluding it.
  3. Cold page cache. Everything here was page-cache warm. Dropping caches between runs (echo 3 > /proc/sys/vm/drop_caches) would show the storage-driver side of the story.
  4. More engines. Docker and nerdctl belong in this comparison before any strong claims.

Reproduce it

Pull alpine once in each engine, then time run --rm <image> true in a loop, alternating engines. If you run this on your own machine — especially as a non-root user, which I couldn't do here — I'd genuinely like to see your numbers. The methodology that matters: same image digest, warmed cache, no cherry-picking, report the setup you actually used.

Links

If you rerun this and get different results, that's useful data, not an argument — post them in the discussion and I'll update this article.

Top comments (0)