io

i/o

reading and writing data - files, sockets, http. zig 0.15 overhauled this entirely, replacing generic anytype interfaces with concrete types that use explicit buffers. the release notes explain why (better error messages, no generic pollution, clearer ownership).

the main thing to know: you provide the buffers, and you call .interface() to get the type that APIs expect.

http server

when handling incoming connections, you set up buffers for reading requests and writing responses:

var read_buffer: [8192]u8 = undefined;
var write_buffer: [8192]u8 = undefined;

var reader = conn.stream.reader(&read_buffer);
var writer = conn.stream.writer(&write_buffer);

var server = http.Server.init(reader.interface(), &writer.interface);

you own these buffers (they're on your stack). the http.Server borrows them. .interface() extracts the concrete reader/writer type.

see: http.zig#L14

http client

when making outgoing requests (calling APIs, fetching data), use Io.Writer.Allocating to collect the response body:

var client = http.Client{ .allocator = allocator };
defer client.deinit();

var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();

const result = client.fetch(.{
    .location = .{ .url = url },
    .response_writer = &aw.writer,
}) catch |err| return err;

if (result.status != .ok) return error.FetchFailed;

const response = aw.written();  // borrow the response body

the allocating writer grows as needed to hold whatever the server sends back.

WARNING: toArrayList() transfers ownership — after calling it, deinit() frees nothing (it resets the internal buffer to empty). this is a silent memory leak when used with defer deinit(). use written() instead to borrow the data while deinit() retains ownership and frees properly. this bug has bitten us twice: once in zlay (fixed in zat v0.2.14, commit 819dffe) and again in the typeahead ingester. both times it caused OOM on long-running processes — ~80KB leaked per HTTP call, exhausting 256MB in ~25 minutes.

see: find-bufo/bot/src/main.zig#L196

tls reading quirk

if you're doing raw tls (not using http.Client), there's a gotcha: when reading, n == 0 doesn't mean end-of-stream. it means "i consumed some input but don't have output yet" - tls may be buffering partial records or handling renegotiation. you have to keep trying:

outer: while (total_read < response_buf.len) {
    var w: std.Io.Writer = .fixed(response_buf[total_read..]);
    while (true) {
        const n = tls_client.reader.stream(&w, .limited(remaining)) catch break :outer;
        if (n != 0) {
            total_read += n;
            break;
        }
        // n == 0: keep trying, tls isn't done yet
    }
}

also, raw tls needs explicit flushes at both the tls layer and the underlying stream:

tls_client.writer.flush() catch return error.Failed;
stream_writer.interface.flush() catch return error.Failed;

see: atproto.zig#L285

when you don't need to flush

the high-level apis handle this for you. http.Server's request.respond() flushes internally. http.Client flushes when the request completes. you only need manual flushes when working with raw streams or tls directly.

gzip decompression — force identity on the low-level API

two separate issues at different layers. both want the same workaround.

0.15.x panic

http.Client panics when decompressing certain gzip responses on x86_64-linux. the deflate decompressor sets up a Writer with unreachableRebase but can hit a code path that calls rebase when the buffer fills. fixed in 0.16.

0.16 low-level API does not auto-decompress

client.fetch(...) handles Content-Encoding transparently. client.request(...) + response.reader(&.{}) + streamRemaining does not — you get the raw gzip bytes and any downstream parser chokes. symptom: parseFromSlice returns SyntaxError, response body starts with 1f 8b.

workaround (works for both)

use the typed headers.accept_encoding slot — not extra_headers:

// WRONG: extra_headers is additive, zig still sends its default
//        "accept-encoding: gzip, deflate, zstd" alongside yours,
//        server happily picks gzip
var req = try client.request(.POST, uri, .{
    .extra_headers = &.{
        .{ .name = "Accept-Encoding", .value = "identity" },
    },
});

// RIGHT: typed slot replaces the client's default
var req = try client.request(.POST, uri, .{
    .headers = .{ .accept_encoding = .{ .override = "identity" } },
});

verified empirically against a real PDS (2026-04-09):

mode wire accept-encoding response content-encoding result
extra_headers alone gzip, deflate, zstd\r\naccept-encoding: identity gzip SyntaxError
headers.accept_encoding = .override("identity") identity (none) parses ok

alternative: use fetch()

if you don't need to hand-build the request (no DPoP, no streaming body), client.fetch() auto-decompresses and you don't have to think about any of this. use it when you can.

see: zat/xrpc.zig, embed-on-pds/backend/src/oauth.zig pdsAuthedRequest