Relay everything.
Allocate nothing.
zoxy is a zero-allocation reverse proxy and load balancer in Zig. Every buffer, slot, and pool is reserved once at startup, so the serving path runs allocation-free on one libxev event loop. When it fills, it sheds — it never crashes, never grows, never queues unboundedly.
One static binary — a ~2,420 KiB download, ~5,200 KiB unpacked on Apple silicon.
- 44kreq/s
- 5-run average on one core — 2.1× the next best of 5 proxies
- 70.1MiB
- zoxy’s own startup banner for this benchmark’s config — reserved once, fixed for the life of the process
- 0allocations
- on the hot path, gate-enforced
Design
A small, sharp proxy core
Two hard constraints, enforced by tests rather than aspired to: nothing allocates on the hot path, and exhaustion sheds load instead of crashing. Everything below follows from those two.
Zero allocation after startup
Every buffer, slot, and pool is reserved once at init; the serving path allocates nothing. A counting-allocator gate fails the build the moment that changes.
One loop, one ring, no locks
One event-loop thread owns all I/O and every buffer on a single io_uring. Single-writer by construction — no locks, no atomics, no cache-line contention.
Sheds load, never falls over
Fixed pools make exhaustion a decision, not a crash. Under overload zoxy sheds the newest work — an RST or static 503 — and keeps admitted traffic moving.
Proven in a deterministic simulator
The real data path runs on virtual sockets and a virtual clock under a seeded adversary. Every response is verified byte-exact; every failure prints a replayable seed.
Health checks eject sick endpoints
A tcp or http probe sweeps each endpoint on its own interval. Three failed checks eject it from rotation; two good ones bring it back automatically.
Benchmark
Six hosts. A real network in the path.
The bench harness runs the load generator, the proxy, and four backend origins on six separate VMs, so a real network sits in the path — the way it would in production. Capped to one core apiece and driven through an identical open-loop ramp by zrk, zoxy sustains the most throughput of the 5 HTTP proxies by a wide margin — 44k req/s, about 2.1× the next best — while 70.1 MiB stays fixed: reserved once at startup and never touched again, so it can't creep upward no matter how long the ramp runs. One core is the honest unit for a share-nothing proxy: you scale zoxy by running one instance per core, so this is the number that stacks.
- zoxy 0.2.0 44k req/s 70.1 MiB
- nginx 1.31 20.7k req/s 58.4 MiB
- haproxy 3.4.3 17.8k req/s 84.1 MiB
- pingora 0.8 11.5k req/s 66.9 MiB
- envoy 1.33 7.8k req/s 77.3 MiB
mean sustained req/s over the last 5 clean runs, each proxy capped to one core
full report — throughput · latency · memory →Scaling
One core, on purpose.
zoxy runs on a single event-loop thread — not because it can't thread, but because a proxy is network-bound and one owner beats any amount of locking. You scale it the way the kernel already knows how: more processes on the same socket.
Network-bound, not CPU-bound
The work is moving bytes and parsing a small request head — not heavy computation. Across six VMs with a real network in the path, capped to a single core, zoxy sustains about 2.1× the HTTP throughput of the next-best proxy on a memory footprint fixed before the first request arrives — reserved at startup, gate-enforced never to grow after. Adding threads to the data path would only add contention to defend a resource that was never the bottleneck.
One owner, zero synchronization
The loop thread owns every socket, buffer, and pool. Nothing crosses a thread boundary, so there are no locks, no atomics, no false sharing, and no accept imbalance to tune. That single-writer discipline is also exactly what lets the deterministic simulator drive the whole data path.
Scale out, not up
When you need more, launch more processes. N independent zoxy instances bind the same port with SO_REUSEPORT and the kernel spreads connections across them. Pin one per core — or per NUMA node — so the NIC’s receive queues line up with the processes. Share-nothing where the kernel actually isolates.
Testing
Proven in a deterministic simulator.
Proxies fail in the seams — a reset mid-relay, a connect that hangs forever, a write that only half-lands. zoxy hunts those bugs before they ship: the same serving code that runs on the libxev event loop also runs inside a deterministic simulator, as adversarial as production and far more patient. It has been there since the first commit.
- The real serving code runs against a virtual network and a virtual clock — no wall-clock flakiness, no real sockets, the same code that runs on the libxev loop in production. A failure prints its seed, and one command replays that exact schedule, faults included.
- A seeded adversary injects partial reads and writes down to one byte, resets at every point in an exchange, refused and black-holed connects, and origins that stall or never answer.
- Every response is verified byte-exact against a token its origin echoes back, and the invariants are always on — no deadlock, every pool drained to zero, every shed counter reconciled. CI sweeps 4096 fresh seeds per change; a nightly soak sweeps a million more from a disjoint, contiguous range, because a rare interleaving is exactly what more schedules buy.
$ zig build sim -- 0 500 # [seed] [iterations]SimIo · virtual clock · seeded adversaryfaults: partial rw · reset · refused · black-holed✓ 500/500 responses verified byte-exact✓ all pools drained · every shed counter reconciled$ zig build sim -- fuzz # forever; every seed replayableConfiguration
A config with no surprises.
Listeners route by host and path to clusters; clusters list static endpoints; optional filters reject, edit headers, or rewrite a prefix by rule. It stays strict JSON, parsed once into an immutable arena at startup — an unknown or duplicate field is a hard error, not a warning.
- A listener sets its protocol — "l4" for raw TCP relay or "http" for the HTTP/1.1 reverse proxy (the default is "l4").
- An http listener maps each request to a cluster through a per-listener longest-prefix route table on the canonical path; an optional per-route "host" scopes a rule, and a bare "cluster" is sugar for one catch-all route.
- Filters are data, not code: match on method, host, canonical path prefix, or a header, then reject, set/add/remove a header, or rewrite a path prefix — compiled at config load into bounded, immutable tables, never a runtime script.
- Strict by design: an unknown or duplicate field — or a non-canonical route prefix or host — is a hard parse error at load, not a warning or a silent mismatch at request time.
- Endpoints are static socket literals — hostnames are rejected, so there is no DNS on the loop. Parsed once into an immutable arena: a config change is a process restart, not a live reload.
{ "listeners": [ { "bind": "0.0.0.0:8080", "protocol": "http", "routes": [ { "prefix": "/api", "cluster": "api" }, { "prefix": "/", "cluster": "web" } ], "filters": [ { "match": { "path_prefix": "/admin" }, "actions": [{ "reject": 403 }] } ] } ], "clusters": { "web": { "endpoints": ["10.0.0.11:8080", "10.0.0.12:8080"] }, "api": { "endpoints": ["10.0.0.21:8080"] } }, "timeouts": { "connect_ms": 5000, "idle_ms": 60000, "drain_deadline_ms": 10000 }}zoxy.json