adoption: what using zio looks like in real consumers#
status update 2026-07-12: milestones 1 AND 2 below are DONE — the vtable splice landed with fiber-aware futex included (Io.Mutex/Condition/Queue correct across domains), plus prompt cancellation. see adoption-guide.md for the practical how-to; remaining pre-zlay items are in that guide's "current maturity" section.
2026-07-10. analysis of the three candidate consumers (zlay, zat, zds), what each would actually change, and the order of work inside zio that adoption demands. grounded in reading each codebase's current io wiring, not vibes.
the punchline#
because zig 0.16 routed everything blocking through the Io vtable, adoption
is not an integration project — it's a backend swap at each app's
composition root, provided zio finishes the vtable ops the app's hot path
touches. libraries (zat, websocket.zig, httpz) need zero changes: they
already take io as a parameter. this was the whole point of the hybrid
design.
zlay (the motivating consumer, biggest win)#
zlay's main.zig is already shaped for this, verbatim:
const Backend = Io.Threaded; // main.zig:62 — the swap point
var backend: Backend = undefined;
...
var pool_io_backend = Io.Threaded.init(...); // separate io for blocking work
- what changes:
const Backend = zio.Zio;(plus build.zig.zon dep). subscribers, broadcaster, websocket.zig — all takeio, none change. - what zio must finish first: fiber-backed implementations of the vtable
ops the subscriber hot path uses —
concurrent(spawn fiber instead of thread),netConnectIp/netRead/netWrite/netClose(suspend fiber, arm engine interest, resume on readiness),sleep,Mutex/Condition(fiber-aware). that's the "fiber scheduler" box in the README. - what stays threaded, by design:
pool_io— frame workers, pg, rocksdb, DID-resolution HTTP. zlay ALREADY runs a two-io split (an artifact of the april cross-Io crash class), so the DbRequestQueue lesson is pre-encoded in its architecture. zio's hybrid delegation makes the split even safer: anything zio hasn't implemented falls through to the inner Threaded instead of failing. - expected result: ~2,800 subscriber threads → fibers on O(cores) loop threads; 8 MB stacks × 2,800 → fiber stacks sized to the websocket read path; ReleaseSafe stays (it's already the prod default — and with the clobber fixes, fibers no longer force ReleaseFast the way april's Evented did).
- the risk to respect: the cross-Io crash class from the ops changelog — an object created on one io driven from another. the two-io split must stay disciplined; zio should add a debug-mode owner check (fd/task tagged with its engine, panic on foreign access) before zlay integration, so violations are loud instead of UB.
- rollout: the
-DbackendA/B pattern from the bench, then simulator soak, then the ops-changelog canary discipline. never straight to prod.
zat (zero-change consumer)#
zat is a library: syntax, dag-cbor, car, mst, did resolution, jwt, key
encoding. everything network- or clock-touching takes io as a parameter;
nothing constructs a backend. using zio in zat means passing zio's io
into zat calls — no zat commit required. the only follow-up worth
considering: zat's did-resolution HTTP helpers currently get whatever io the
caller passes; callers doing bulk resolution (zlay's validator) should keep
passing a threaded/pool io until zio implements netLookup + TLS-over-
evented, since std.http.Client's TLS handshake does real crypto work between
reads (fine on a fiber, but long-poll-ish; measure before moving it).
zds (different shape: server, not fan-in client)#
zds is a PDS — httpz HTTP/1.1 server inbound, modest outbound (did resolution, websocket upgrades via the shared websocket.zig). its connection count is nowhere near zlay's; thread-per-connection is not currently a pain there.
- short term: nothing to do. zds's bottleneck isn't thread glut.
- when it matters: if/when zds serves many long-lived subscriptions (event streams / firehose-style endpoints) or high concurrent request counts, the same swap applies — but through httpz's io usage, which we control (first-party fork policy already in zds's README). the engine's accept/listen path (built for the bench server) is the relevant surface.
- strategic value: zds keeps us honest that zio serves the server shape too, not just fan-in clients. the bench already models both sides.
what this ordering implies for zio's roadmap#
adoption pressure sorts the remaining work:
- fiber scheduler on the proven primitive (in progress conceptually:
primitive gauntleted, scheduler unstarted): fiber pool per engine loop,
park/resume tied to poller events,
concurrent→ fiber spawn. - evented vtable ops on Zio: net{ConnectIp,Read,Write,Close,ListenIp, Accept}, sleep (timer wheel or timerfd/kqueue timers), then Mutex/Condition fiber integration. everything else keeps delegating.
- cross-io owner check (debug assert) — cheap, prevents the worst known integration failure mode.
- TLS reality check: zlay's PDS connections are wss:// — the read path under fibers includes TLS record decryption. needs a bench column before integration (frames/s through std.crypto.tls on fibers vs threads).
- then the zlay
-Dbackendswap, simulator soak, canary.
io_uring stays parked: epoll + fibers is the adoption-critical path; completion-mode is a later perf pass behind the same vtable.
the honest unknowns#
- fiber stack sizing for the websocket+TLS read path (upstream Uring uses 60 MB virtual reservations per fiber; we'll want measured, smaller stacks with guard pages — mmap PROT_NONE guards, not allocator arrays).
- std.http.Client + std.crypto.tls behavior when its io suspends mid- handshake (should be transparent — that's the vtable contract — but "should" is not a measurement).
- cancellation semantics: Threaded implements real cancellation; our fiber ops must match its observable behavior or zlay's shutdown paths (already historically fragile — see ops changelog GPF-on-shutdown entries) will find the difference.