Overview
This chapter graduates from local files and threads to sockets, using Zig’s std.net and std.http packages to move data between processes in a disciplined way. For background, see net.zig. We will build a minimal loopback server, explore handshakes, and layer an HTTP/JSON workflow on top to demonstrate how these pieces compose.
Zig 0.15.2’s I/O redesign removes legacy buffered helpers, so we will adopt the modern std.Io.Reader/std.Io.Writer interface and show how to manage framing manually when necessary. See Reader.zig and v0.15.2.
The Network Stack Architecture
Before diving into socket code, it’s essential to understand how std.net fits into Zig’s standard library architecture. The following diagram shows the complete layering from high-level networking APIs down to system calls:
This layered design mirrors the filesystem architecture from Chapter 28: std.net provides high-level, portable networking abstractions (Address, Stream, Server), which flow through std.posix for cross-platform POSIX socket compatibility, which then dispatches to platform-specific implementations—either direct syscalls on Linux (socket, bind, listen, accept) or Win32 Winsock APIs on Windows. When you call Address.listen(), the request traverses these layers: std.net.Address → std.posix.socket() → std.os.linux.socket() (or std.os.windows.WSASocketW()) → kernel. This explains why WASI builds fail on socket operations—the WASI layer lacks socket support in most runtimes. Understanding this architecture helps you reason about error handling (errors bubble up from syscalls), debug platform-specific issues, and make informed decisions about libc linking for maximum portability.
Learning Goals
The goals in this module revolve around the networking primitives in std.net and the HTTP stack built on them (Server.zig, Client.zig). You will learn how to:
- Compose a loopback service with
std.net.Address.listenthat promptly accepts connections and coordinates readiness withstd.Thread.ResetEvent. - Implement newline-oriented framing using the new
std.Io.Readerhelpers rather than deprecated buffered adapters. - Call
std.http.Client.fetch, capture the response stream, and parse JSON payloads withstd.jsonutilities. json.zig
Socket building blocks
std.net exposes cross-platform TCP primitives that mirror the POSIX socket lifecycle while integrating with Zig’s error semantics and resource management. Pairing them with std.Thread.ResetEvent lets us synchronise a server thread’s readiness with a client, without resorting to polling. ResetEvent.zig
Loopback handshake walkthrough
The following example binds to 127.0.0.1, accepts a single client, and echoes the trimmed line it received. Because Zig’s reader API no longer offers convenience line readers, the sample implements a readLine helper with Reader.takeByte, demonstrating how to build that functionality directly.
const std = @import("std");
/// Arguments passed to the server thread so it can accept exactly one client and reply.
const ServerTask = struct {
server: *std.net.Server,
ready: *std.Thread.ResetEvent,
};
/// Reads a single line from a `std.Io.Reader`, stripping the trailing newline.
/// Returns `null` when the stream ends before any bytes are read.
fn readLine(reader: *std.Io.Reader, buffer: []u8) !?[]const u8 {
var len: usize = 0;
while (true) {
// Attempt to read a single byte from the stream
const byte = reader.takeByte() catch |err| switch (err) {
error.EndOfStream => {
// Stream ended: return null if no data was read, otherwise return what we have
if (len == 0) return null;
return buffer[0..len];
},
else => return err,
};
// Complete the line when newline is encountered
if (byte == '\n') return buffer[0..len];
// Skip carriage returns to handle both Unix (\n) and Windows (\r\n) line endings
if (byte == '\r') continue;
// Guard against buffer overflow
if (len == buffer.len) return error.StreamTooLong;
buffer[len] = byte;
len += 1;
}
}
/// Blocks waiting for a single client, echoes what the client sent, then exits.
fn serveOne(task: ServerTask) void {
// Signal the main thread that the server thread reached the accept loop.
// This synchronization prevents the client from attempting connection before the server is ready.
task.ready.set();
// Block until a client connects; handle connection errors gracefully
const connection = task.server.accept() catch |err| {
std.debug.print("accept failed: {s}\n", .{@errorName(err)});
return;
};
// Ensure the connection is closed when this function exits
defer connection.stream.close();
// Set up a buffered reader to receive data from the client
var inbound_storage: [128]u8 = undefined;
var net_reader = connection.stream.reader(&inbound_storage);
const conn_reader = net_reader.interface();
// Read one line from the client using our custom line-reading logic
var line_storage: [128]u8 = undefined;
const maybe_line = readLine(conn_reader, &line_storage) catch |err| {
std.debug.print("receive failed: {s}\n", .{@errorName(err)});
return;
};
// Handle case where connection closed without sending data
const line = maybe_line orelse {
std.debug.print("connection closed before any data arrived\n", .{});
return;
};
// Clean up any trailing whitespace from the received line
const trimmed = std.mem.trimRight(u8, line, "\r\n");
// Build a response message that echoes what the server observed
var response_storage: [160]u8 = undefined;
const response = std.fmt.bufPrint(&response_storage, "server observed \"{s}\"\n", .{trimmed}) catch |err| {
std.debug.print("format failed: {s}\n", .{@errorName(err)});
return;
};
// Send the response back to the client using a buffered writer
var outbound_storage: [128]u8 = undefined;
var net_writer = connection.stream.writer(&outbound_storage);
net_writer.interface.writeAll(response) catch |err| {
std.debug.print("write error: {s}\n", .{@errorName(err)});
return;
};
// Ensure all buffered data is transmitted before the connection closes
net_writer.interface.flush() catch |err| {
std.debug.print("flush error: {s}\n", .{@errorName(err)});
return;
};
}
pub fn main() !void {
// Initialize allocator for dynamic memory needs
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Create a loopback server on 127.0.0.1 with an OS-assigned port (port 0)
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try address.listen(.{ .reuse_address = true });
defer server.deinit();
// Create a synchronization primitive to coordinate server readiness
var ready = std.Thread.ResetEvent{};
// Spawn the server thread that will accept and handle one connection
const server_thread = try std.Thread.spawn(.{}, serveOne, .{ServerTask{
.server = &server,
.ready = &ready,
}});
// Ensure the server thread completes before main() exits
defer server_thread.join();
// Block until the server thread signals it has reached accept()
// This prevents a race condition where the client tries to connect too early
ready.wait();
// Retrieve the dynamically assigned port number and connect as a client
const port = server.listen_address.in.getPort();
var stream = try std.net.tcpConnectToHost(allocator, "127.0.0.1", port);
defer stream.close();
// Send a test message to the server using a buffered writer
var outbound_storage: [64]u8 = undefined;
var client_writer = stream.writer(&outbound_storage);
const payload = "ping over loopback\n";
try client_writer.interface.writeAll(payload);
// Force transmission of buffered data
try client_writer.interface.flush();
// Receive the server's response using a buffered reader
var inbound_storage: [128]u8 = undefined;
var client_reader = stream.reader(&inbound_storage);
const client_reader_iface = client_reader.interface();
var reply_storage: [128]u8 = undefined;
const maybe_reply = try readLine(client_reader_iface, &reply_storage);
const reply = maybe_reply orelse return error.EmptyReply;
// Strip any trailing whitespace from the server's reply
const trimmed = std.mem.trimRight(u8, reply, "\r\n");
// Display the results to stdout using a buffered writer for efficiency
var stdout_storage: [256]u8 = undefined;
var stdout_state = std.fs.File.stdout().writer(&stdout_storage);
const out = &stdout_state.interface;
try out.writeAll("loopback handshake succeeded\n");
try out.print("client received: {s}\n", .{trimmed});
// Ensure all output is visible before program exits
try out.flush();
}
$ zig run 01_loopback_ping.zigloopback handshake succeeded
client received: server observed "ping over loopback"std.Thread.ResetEvent provides an inexpensive latch for announcing that the server thread reached accept, ensuring the client connection attempt does not race ahead.
Managing framing explicitly
Reading a line requires awareness of how the new reader interface delivers bytes: takeByte yields one byte at a time and reports error.EndOfStream, which we convert to either null (no data) or a completed slice. This manual framing encourages you to think about protocol boundaries rather than relying on an implicit buffered reader, and mirrors the intent of the 0.15.2 I/O overhaul.
HTTP pipelines in Zig
With sockets in hand, we can step up a level: Zig’s standard library ships an HTTP server and client implemented entirely in Zig, letting you serve endpoints and perform requests without third-party dependencies.
Serving JSON from a loopback listener
The server thread in the next example wraps the accepted stream with std.http.Server, parses one request, and emits a compact JSON body. Notice how we pre-render the response into a fixed buffer, so request.respond can advertise the content length accurately. Writer.zig
Fetching and decoding with
The companion client uses std.http.Client.fetch to perform a GET request, collects the body via a fixed writer and decodes it into a strongly typed struct using std.json.parseFromSlice. The same routine can be extended to follow redirects, stream large payloads, or negotiate TLS, depending on your needs. static.zig
const std = @import("std");
/// Arguments passed to the HTTP server thread so it can respond to a single request.
const HttpTask = struct {
server: *std.net.Server,
ready: *std.Thread.ResetEvent,
};
/// Minimal HTTP handler: accept one client, reply with a JSON document, and exit.
fn serveJson(task: HttpTask) void {
// Signal the main thread that the server thread reached the accept loop.
// This synchronization prevents the client from attempting connection before the server is ready.
task.ready.set();
// Block until a client connects; handle connection errors gracefully
const connection = task.server.accept() catch |err| {
std.debug.print("accept failed: {s}\n", .{@errorName(err)});
return;
};
// Ensure the connection is closed when this function exits
defer connection.stream.close();
// Allocate buffers for receiving HTTP request and sending HTTP response
var recv_buffer: [4096]u8 = undefined;
var send_buffer: [4096]u8 = undefined;
// Create buffered reader and writer for the TCP connection
var conn_reader = connection.stream.reader(&recv_buffer);
var conn_writer = connection.stream.writer(&send_buffer);
// Initialize HTTP server state machine with the buffered connection interfaces
var server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface);
// Parse the HTTP request headers (method, path, version, etc.)
var request = server.receiveHead() catch |err| {
std.debug.print("receive head failed: {s}\n", .{@errorName(err)});
return;
};
// Define the shape of our JSON response payload
const Body = struct {
service: []const u8,
message: []const u8,
method: []const u8,
path: []const u8,
sequence: u32,
};
// Build a response that echoes request details back to the client
const payload = Body{
.service = "loopback-api",
.message = "hello from Zig HTTP server",
.method = @tagName(request.head.method), // Convert HTTP method enum to string
.path = request.head.target, // Echo the requested path
.sequence = 1,
};
// Allocate a buffer for the JSON-encoded response body
var json_buffer: [256]u8 = undefined;
// Create a fixed-size writer that writes into our buffer
var body_writer = std.Io.Writer.fixed(json_buffer[0..]);
// Serialize the payload struct into JSON format
std.json.Stringify.value(payload, .{}, &body_writer) catch |err| {
std.debug.print("json encode failed: {s}\n", .{@errorName(err)});
return;
};
// Get the slice containing the actual JSON bytes written
const body = std.Io.Writer.buffered(&body_writer);
// Send HTTP 200 response with the JSON body and appropriate content-type header
request.respond(body, .{
.extra_headers = &.{
.{ .name = "content-type", .value = "application/json" },
},
}) catch |err| {
std.debug.print("respond failed: {s}\n", .{@errorName(err)});
return;
};
}
pub fn main() !void {
// Initialize allocator for dynamic memory needs (HTTP client requires allocation)
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Create a loopback server on 127.0.0.1 with an OS-assigned port (port 0)
const address = try std.net.Address.parseIp("127.0.0.1", 0);
var server = try address.listen(.{ .reuse_address = true });
defer server.deinit();
// Create a synchronization primitive to coordinate server readiness
var ready = std.Thread.ResetEvent{};
// Spawn the server thread that will accept and handle one HTTP request
const server_thread = try std.Thread.spawn(.{}, serveJson, .{HttpTask{
.server = &server,
.ready = &ready,
}});
// Ensure the server thread completes before main() exits
defer server_thread.join();
// Block until the server thread signals it has reached accept()
// This prevents a race condition where the client tries to connect too early
ready.wait();
// Retrieve the dynamically assigned port number for the client connection
const port = server.listen_address.in.getPort();
// Initialize HTTP client with our allocator
var client = std.http.Client{ .allocator = allocator };
defer client.deinit();
// Construct the full URL for the HTTP request
var url_buffer: [64]u8 = undefined;
const url = try std.fmt.bufPrint(&url_buffer, "http://127.0.0.1:{d}/stats", .{port});
// Allocate buffer to receive the HTTP response body
var response_buffer: [512]u8 = undefined;
// Create a fixed-size writer that will capture the response
var response_writer = std.Io.Writer.fixed(response_buffer[0..]);
// Perform the HTTP GET request with custom User-Agent header
const fetch_result = try client.fetch(.{
.location = .{ .url = url },
.response_writer = &response_writer, // Where to write response body
.headers = .{
.user_agent = .{ .override = "zigbook-demo/0.15.2" },
},
});
// Get the slice containing the actual response body bytes
const body = std.Io.Writer.buffered(&response_writer);
// Define the expected structure of the JSON response
const ResponseShape = struct {
service: []const u8,
message: []const u8,
method: []const u8,
path: []const u8,
sequence: u32,
};
// Parse the JSON response into a typed struct
var parsed = try std.json.parseFromSlice(ResponseShape, allocator, body, .{});
// Free the memory allocated during JSON parsing
defer parsed.deinit();
// Set up a buffered writer for stdout to efficiently output results
var stdout_storage: [256]u8 = undefined;
var stdout_state = std.fs.File.stdout().writer(&stdout_storage);
const out = &stdout_state.interface;
// Display the HTTP response status code
try out.print("status: {d}\n", .{@intFromEnum(fetch_result.status)});
// Display the parsed JSON fields
try out.print("service: {s}\n", .{parsed.value.service});
try out.print("method: {s}\n", .{parsed.value.method});
try out.print("path: {s}\n", .{parsed.value.path});
try out.print("message: {s}\n", .{parsed.value.message});
// Ensure all output is visible before program exits
try out.flush();
}
$ zig run 02_http_fetch_and_json.zigstatus: 200
service: loopback-api
method: GET
path: /stats
message: hello from Zig HTTP serverClient.fetch defaults to keep-alive connections and automatically reuses sockets from its pool. When you feed it a fixed writer, the writer returns error.WriteFailed if your buffer is too small. Size it to cover the payload you expect, or fall back to an allocator-backed writer.
JSON tooling essentials
std.json.Stringify and std.json.parseFromSlice let you stay in typed Zig data while emitting or consuming JSON text, provided you pay attention to allocation strategy. In these examples, we employ std.Io.Writer.fixed to build bodies without heap activity, and we release parse results with Parsed.deinit() once done. Stringify.zig
Understanding the Writer Abstraction
Both HTTP response generation and JSON serialization rely on Zig’s Writer interface. The following diagram shows the writer abstraction and its key implementations:
The Writer abstraction provides a unified interface for output operations, with three main implementation strategies. Fixed buffer writers (std.Io.Writer.fixed(buffer)) write to a pre-allocated buffer and return error.WriteFailed when the buffer is full—this is what the HTTP example uses to build response bodies with zero heap allocation. Allocating writers dynamically grow their buffer using an allocator, suitable for unbounded output like streaming large JSON documents. Discarding writers count bytes without storing them, useful for calculating content length before actually writing. The write methods provide a consistent API regardless of the underlying implementation: writeAll for raw bytes, print for formatted output, writeByte for single bytes, and specialized methods like printInt for numeric formatting. When you call std.json.stringify(value, .{}, writer), the JSON serializer doesn’t care whether writer is fixed, allocating, or discarding—it just calls writeAll and the writer implementation handles the details. This is why the chapter mentions 'size it to cover the payload you expect or fall back to an allocator-backed writer'—you’re choosing between bounded fixed buffers (fast, no allocation, can overflow) and dynamic allocating buffers (flexible, heap overhead, no size limit).
Notes & Caveats
- TCP loopback servers still block the current thread on
accept; when targeting single-threaded builds, you must branch onbuiltin.single_threadedto avoid spawning. builtin.zig - The HTTP client rescans system trust stores the first time you make an HTTPS request; if you vend your own certificate bundle, toggle
client.next_https_rescan_certsaccordingly. - The new I/O APIs expose raw buffers, so ensure your fixed writers and readers have enough capacity before reusing them across requests.
Exercises
- Extend the loopback handshake to accept multiple clients by storing handles in a slice and joining them after broadcasting a shutdown message. Thread.zig
- Add a
--headflag to the HTTP example that issues aHEADrequest and prints the negotiated headers, inspectingResponse.headfor metadata. - Replace the manual
readLinehelper withReader.discardDelimiterLimitto compare behaviour and error handling under the new I/O contracts.
Caveats, alternatives, edge cases
- Not every Zig target supports sockets; WASI builds, for instance, will fail during
Address.listen, so guard availability by inspecting the target OS tag. - TLS requests require a certificate bundle; embed one with
Client.ca_bundlewhen running in environments without system stores (CI, containers, early boot environments). std.json.parseFromSliceloads the whole document into memory; for large payloads prefer the streamingstd.json.ScannerAPI to process tokens incrementally. Scanner.zig
Summary
std.netandstd.Io.Readergive you the raw tools to accept connections, manage framing, and synchronise readiness across threads in a predictable way.std.http.Serverandstd.http.Clientsit naturally atopstd.net, providing composable building blocks for REST-style services without external dependencies.std.jsonrounds out the story by turning on-wire data into typed structs and back, keeping ownership explicit so you can choose between fixed buffers and heap-backed writers.