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