Chapter 31Networking Http And Json

Networking, HTTP, and JSON

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:

graph TB subgraph "User Code" APP[Application Code] end subgraph "High-Level APIs (lib/std)" FS["std.fs<br/>(fs.zig)"] NET["std.net<br/>(net.zig)"] PROCESS["std.process<br/>(process.zig)"] FMT["std.fmt<br/>(fmt.zig)"] HEAP["std.heap<br/>(heap.zig)"] end subgraph "Mid-Level Abstractions" POSIX["std.posix<br/>(posix.zig)<br/>Cross-platform POSIX API"] OS["std.os<br/>(os.zig)<br/>OS-specific wrappers"] MEM["std.mem<br/>(mem.zig)<br/>Memory utilities"] DEBUG["std.debug<br/>(debug.zig)<br/>Stack traces, assertions"] end subgraph "Platform Layer" LINUX["std.os.linux<br/>(os/linux.zig)<br/>Direct syscalls"] WINDOWS["std.os.windows<br/>(os/windows.zig)<br/>Win32 APIs"] WASI["std.os.wasi<br/>(os/wasi.zig)<br/>WASI APIs"] LIBC["std.c<br/>(c.zig)<br/>C interop"] end subgraph "System Layer" SYSCALL["System Calls"] KERNEL["Operating System"] end APP --> FS APP --> NET APP --> PROCESS APP --> FMT APP --> HEAP FS --> POSIX NET --> POSIX PROCESS --> POSIX FMT --> MEM HEAP --> MEM POSIX --> OS OS --> LIBC OS --> LINUX OS --> WINDOWS OS --> WASI DEBUG --> OS LINUX --> SYSCALL WINDOWS --> SYSCALL WASI --> SYSCALL LIBC --> SYSCALL SYSCALL --> KERNEL

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.Addressstd.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.listen that promptly accepts connections and coordinates readiness with std.Thread.ResetEvent.
  • Implement newline-oriented framing using the new std.Io.Reader helpers rather than deprecated buffered adapters.
  • Call std.http.Client.fetch, capture the response stream, and parse JSON payloads with std.json utilities. 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.

Zig
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();
}
Run
Shell
$ zig run 01_loopback_ping.zig
Output
Shell
loopback 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

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();
}
Run
Shell
$ zig run 02_http_fetch_and_json.zig
Output
Shell
status: 200
service: loopback-api
method: GET
path: /stats
message: hello from Zig HTTP server

Client.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:

graph TB WRITER["Writer"] subgraph "Writer Types" FIXED["fixed(buffer)"] ALLOC["Allocating"] DISCARD["Discarding"] end WRITER --> FIXED WRITER --> ALLOC WRITER --> DISCARD subgraph "Write Methods" PRINT["print(fmt, args)"] PRINTVAL["printValue(specifier, options, value, depth)"] PRINTINT["printInt(value, base, case, options)"] WRITEBYTE["writeByte(byte)"] WRITEALL["writeAll(bytes)"] end WRITER --> PRINT WRITER --> PRINTVAL WRITER --> PRINTINT WRITER --> WRITEBYTE WRITER --> WRITEALL

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 on builtin.single_threaded to 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_certs accordingly.
  • 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 --head flag to the HTTP example that issues a HEAD request and prints the negotiated headers, inspecting Response.head for metadata.
  • Replace the manual readLine helper with Reader.discardDelimiterLimit to 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_bundle when running in environments without system stores (CI, containers, early boot environments).
  • std.json.parseFromSlice loads the whole document into memory; for large payloads prefer the streaming std.json.Scanner API to process tokens incrementally. Scanner.zig

Summary

  • std.net and std.Io.Reader give you the raw tools to accept connections, manage framing, and synchronise readiness across threads in a predictable way.
  • std.http.Server and std.http.Client sit naturally atop std.net, providing composable building blocks for REST-style services without external dependencies.
  • std.json rounds 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.

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.