Async/Await is finally back in Zig

November 1, 2025

I’ve been following the Zig programming language with keen interest over the past several months. I’ve been eagerly awaiting the new async/await API’s release ever since I watched this video.

The foundation for this long-awaited feature arrived via pull request #25592. The API first shipped in Zig 0.16.0. This article has been updated to reflect the 0.17.0 API, which has breaking changes from 0.16.

Why This Matters

Zig removed async/await support from earlier versions while the team completely redesigned the API from the ground up. The goal is to create something entirely new that differs from what other languages like Go, JavaScript, and Rust do. The new async I/O framework first shipped in 0.16.0.

The Problem of Colored Functions

To understand why this redesign matters, consider JavaScript’s function model. There are regular functions and async functions - two distinct “colors” that don’t mix well. You can call regular functions from within async functions, but async functions cannot be called from regular functions without special handling.

Those who lived through JavaScript’s initial async/await rollout will remember the pain. The migration was brutal. Projects like Node.js had to maintain dual standard libraries: one callback-based and another using async/await. Codebases were effectively split in two.

The New API

The IO API mirrors the Allocator interface: swap the implementation, the rest of the codebase doesn’t change. There are two ways to get an Io: the std.process.Init entry point (the recommended path), or constructing it manually.

Juicy main

The preferred entry point gives you an allocator and a platform-appropriate Io already set up:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;   // always std.Io.Threaded in 0.17
    const gpa = init.gpa; // general-purpose, leak-checked in Debug mode
    _ = gpa;
    _ = io;
}

Manual Io construction

When you need full control, like benchmarks, tests, or explicit thread counts:

// Single-threaded (no heap allocation needed)
var io_impl: std.Io.Threaded = .init_single_threaded;
defer io_impl.deinit();
const io = io_impl.io();
// Multi-threaded — thread count defaults to CPU count minus one
var io_impl: std.Io.Threaded = .init(gpa, .{});
defer io_impl.deinit();
const io = io_impl.io();
// Explicit thread count
var io_impl: std.Io.Threaded = .init(gpa, .{ .async_limit = .limited(4) });
defer io_impl.deinit();
const io = io_impl.io();
// Event-driven (io_uring on Linux, libdispatch on macOS)
var event_io: std.Io.Evented = undefined;
try event_io.init(gpa, .{});
defer event_io.deinit();
const io = event_io.io();

std.Io.Evented resolves to std.Io.Uring on Linux and std.Io.Dispatch (Grand Central Dispatch) on macOS Uring is fully usable in 0.17. Dispatch compiles but has a known stdlib bug in the 0.17.0 release (processReplacePath missing from VTable) — use std.Io.Threaded on macOS for now.

Error Handling and Cancellation

One of the most important patterns in Zig’s new async I/O model is proper resource cleanup. As Andrew Kelley emphasizes in his article about the new Zig feature, directly chaining try with await can cause resource leaks when errors occur early in your code, skipping subsequent cleanup operations.

Both cancel and await are idempotent operations that share identical semantics, with one key difference: cancellation also requests task termination. This means you can safely call cancel on a task that has already been completed - the operations are designed to be safe and predictable. During my tests, if the cancel function is not used, the operation hangs.

Here’s the pattern you should follow:

var task1 = io.async(someOperation, .{args});
defer task1.cancel(io) catch {};

var task2 = io.async(anotherOperation, .{args});
defer task2.cancel(io) catch {};

// Await all tasks BEFORE handling errors
try task1.await(io);
try task2.await(io);

The key insight is to always use defer cancel() immediately after creating each task, then await all tasks before handling any errors. Same pattern as defer deinit() after an allocation: cancel is idempotent, so calling it on a completed task is safe.

Async vs. Concurrent: A Critical Distinction

Asynchronous code does not automatically mean concurrent execution. This distinction is essential for understanding how Zig’s async I/O works.

Using a single-threaded pool with the async function can result in deadlock in producer-consumer patterns. The async function simply separates the call to a function from its return - it does not guarantee that multiple operations will run concurrently.

This is where the concurrent function becomes important. Unlike async, concurrent explicitly expresses concurrency needs. If you use concurrent on a truly single-threaded system, it will fail with error.ConcurrencyUnavailable rather than deadlocking, which is great, as it can be treated in the error handling flow.

Rule of thumb: reach for concurrent when you need things to actually run at the same time, not just to schedule work.

Concurrent Execution in Practice

Here’s a complete example demonstrating true concurrent execution with multiple HTTP requests:

const std = @import("std");
const HostName = std.Io.net.HostName;

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const gpa = init.gpa;
    const host_name: HostName = try .init("example.com");

    // Run 3 requests concurrently
    var results: [3]usize = undefined;

    // Start all concurrent tasks
    var task1 = try io.concurrent(request_website, .{ gpa, io, host_name, 0, &results[0] });
    defer task1.cancel(io) catch {};

    var task2 = try io.concurrent(request_website, .{ gpa, io, host_name, 1, &results[1] });
    defer task2.cancel(io) catch {};

    var task3 = try io.concurrent(request_website, .{ gpa, io, host_name, 2, &results[2] });
    defer task3.cancel(io) catch {};

    // Wait for all tasks to complete
    try task1.await(io);
    try task2.await(io);
    try task3.await(io);

    std.log.info("All requests completed successfully!", .{});
    std.log.info("Results: {any}", .{results});
}

fn request_website(gpa: std.mem.Allocator, io: std.Io, host_name: HostName, index: usize, result: *usize) !void {
    var http_client: std.http.Client = .{ .allocator = gpa, .io = io };
    defer http_client.deinit();

    var request = try http_client.request(.HEAD, .{
        .scheme = "http",
        .host = .{ .percent_encoded = host_name.bytes },
        .port = 80,
        .path = .{ .percent_encoded = "/" },
    }, .{});
    defer request.deinit();

    try request.sendBodiless();

    var redirect_buffer: [1024]u8 = undefined;

    const response = try request.receiveHead(&redirect_buffer);
    std.log.info("Index {d} received {d} {s}", .{ index, response.head.status, response.head.reason });
    result.* = index;
}

Running this program produces output like:

info: Index 2 received 200 OK
info: Index 1 received 200 OK
info: Index 0 received 200 OK
info: All requests completed successfully!
info: Results: { 0, 1, 2 }

Notice how the HTTP responses arrive in non-deterministic order (2→1→0 in this run), demonstrating true concurrent execution. Despite the completion order varying, each task correctly writes to its designated position in the results array, showing proper concurrent data handling. The defer task.cancel(io) ensures cleanup happens even if errors occur.

Learn More