Many programming languages make asynchronous I/O visible in every function signature. A function that awaits network or file work must be declared differently, its callers must also become asynchronous, and libraries often split into blocking and non-blocking variants. This is the function-coloring problem: once a call chain touches asynchronous work, the color spreads through otherwise ordinary code.
Zig’s newer async I/O direction attacks that problem by making I/O an injected capability rather than a special function kind. Code receives a std.Io interface, much like Zig code already receives an Allocator. The same function can run over a blocking implementation, a thread-backed implementation, or an event-loop implementation without changing the function’s type. Asynchrony becomes an execution strategy chosen by the application boundary, not a property that each library function must advertise.
The design is still evolving, but the mechanism is valuable beyond Zig: separate the program semantics from the scheduling backend. Sequential I/O calls remain sequential from the programmer’s perspective. Explicit operations such as io.async, Future.await, Future.cancel, and io.asyncConcurrent express where operations may be reordered, canceled, or required to overlap. The backend then decides whether that maps to direct system calls, a pool of operating-system threads, green threads backed by platform async APIs, or stackless coroutine state machines.
A capability-passed API makes environmental behavior explicit. Instead of calling global file or socket functions, a function accepts an Io value and uses methods or types routed through that value. The caller controls the concrete implementation. Tests can supply a fake implementation, a command-line tool can use a blocking implementation, and a server can use an evented implementation while sharing the same library code.
Colorblind code does not split into read() and readAsync() families. A parser, client, or file writer can be authored once. If it only performs sequential I/O, the evented runtime may still suspend the current task when an operation would block. If the function knows two operations are independent, it can explicitly express that independence using futures without requiring a separate asynchronous version of the whole API.
The design distinguishes operations that may complete out of order from operations that must execute at the same time for correctness. io.async says the operation can be scheduled independently and later awaited. io.asyncConcurrent says overlap is required, such as accepting connections while another part of the program handles user input. That distinction matters because a blocking implementation can preserve correctness for many asynchronous expressions, but it cannot safely simulate required concurrency without threads.
In this model, straight-line I/O code is still the default. Developers add explicit futures only when the program semantics allow reordering or overlap. The same source can then run correctly on a simple blocking backend and faster on an evented backend, provided the code accurately states which work is independent and which work requires concurrency.
The first change is mechanical: functions that touch files, sockets, timers, or standard streams accept an Io parameter. The function body remains direct and idiomatic. It can use try for error propagation and defer for cleanup because the asynchronous behavior is hidden behind the interface implementation.
const std = @import("std");
const Io = std.Io;
fn saveFile(io: Io, data: []const u8, name: []const u8) !void {
const file = try Io.Dir.cwd().createFile(io, name, .{});
defer file.close(io);
try file.writeAll(io, data);
}
fn main() !void {
var io = Io.Threaded.init(.{});
defer io.deinit();
try saveFile(io.io(), "payload", "out.txt");
}
The reusable function does not know whether writeAll maps to direct blocking system calls, work submitted to a thread pool, or an event loop using primitives such as io_uring or kqueue. It only knows that once writeAll returns, the write has completed or an error has been reported.
When two operations have no required ordering, the function can make that independence visible by creating futures. A blocking implementation can run the work immediately and make await effectively trivial. An evented implementation can let both operations make progress before either result is consumed.
fn savePair(io: Io, data: []const u8) !void {
var a = io.async(saveFile, .{ io, data, "a.txt" });
defer a.cancel(io) catch {};
var b = io.async(saveFile, .{ io, data, "b.txt" });
defer b.cancel(io) catch {};
try a.await(io);
try b.await(io);
}
The cancellation defers are part of the pattern, not decoration. Futures represent resources. If an early error path returns before every future is awaited, unfinished work can leak or continue after the parent scope has logically failed. Making cancellation idempotent lets the same cleanup path handle completed, pending, and canceled futures.
The caller selects the implementation at the top of the program. A blocking backend optimizes for small binaries and predictable control flow. A thread-pool backend keeps blocking system calls but introduces parallelism. A green-thread backend can multiplex tasks over async operating-system APIs and stack switching. A stackless-coroutine backend can target platforms where stack switching is unavailable, such as WebAssembly, but requires compiler support to transform function bodies into resumable state machines.
Database clients, HTTP clients, object-storage libraries, and file-processing tools often need to serve both command-line and server workloads. A colorblind I/O interface lets the library expose one API. The command-line tool can use blocking I/O for simplicity, while the server can run the same library over an evented backend without forking the package ecosystem.
Applications can choose an I/O policy based on deployment environment. A batch converter may prefer direct file operations. A high-throughput proxy may prefer an event loop. A portable WebAssembly component may need stackless coroutines. Keeping that choice outside library signatures allows a codebase to experiment with runtimes without rewriting business logic.
Futures become a concrete handle for cleanup. The parent scope can cancel outstanding work when a peer operation fails, when a timeout expires, or when shutdown begins. This is especially important for file, DNS, and network operations where an abandoned future may retain buffers, descriptors, or backend queue entries.
Treat Io like an allocator: pass it to functions that need it, store it in long-lived components that own I/O behavior, and avoid hidden globals. This makes the dependency visible during review and prevents library code from silently choosing a runtime that conflicts with the host application.
const Client = struct {
io: Io,
reader: Io.Reader,
writer: Io.Writer,
pub fn request(self: *Client, body: []const u8) !void {
try self.writer.writeAll(self.io, body);
try self.writer.flush(self.io);
}
};
Do not wrap every operation in io.async. A future should mean that the program is correct if the operation starts now and completes later relative to its siblings. If one operation must happen after another, keep the code sequential. If two operations must overlap for correctness, use the concurrency-specific primitive and be aware that not every backend can support it.
fn runClientAndServer(io: Io) !void {
const socket = try openServerSocket(io);
var server = try io.asyncConcurrent(acceptLoop, .{ io, socket });
defer server.cancel(io) catch {};
// This can be asynchronous after the listener exists.
var client = io.async(startClient, .{ io });
defer client.cancel(io) catch {};
try client.await(io);
}
A low-level I/O interface should expose operations the backend can optimize, not only byte-at-a-time reads and writes. Zig’s design discussions include semantic writer operations such as file-to-writer transfer and vectorized writes. Those let an HTTP response path preserve zero-copy or scatter-gather opportunities through layers of abstraction.
io.async is appropriate when operations may complete out of order. It is not enough when two tasks must actively run at the same time for correctness.