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.

one thread · one io_uring · three pools reserved at startup · clients → proxy → origins

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.

last 30 runs · newest 20260813-131619 refreshed every 6h L7 · HTTP/1.1
020k40k60k80k6 Aug13 Aug30 runs
  • 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.

SO_REUSEPORT
zoxy core 0
zoxy core 1
zoxy core 2
zoxy core 3
one port · N share-nothing processes · one pinned to each core
  • 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.
deterministic simulator
$ zig build sim -- 0 500 # [seed] [iterations]SimIo · virtual clock · seeded adversaryfaults: partial rw · reset · refused · black-holed500/500 responses verified byte-exactall pools drained · every shed counter reconciled$ zig build sim -- fuzz # forever; every seed replayable

Configuration

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.
zoxy.json
{  "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  }}
Every field, bound and default for zoxy.json