If you’ve written network code in earlier versions of Zig or in C, then the patterns here will feel familiar. This post walks through building a minimal HTTP/1.1 server using nothing but the Zig standard library.
The full source code for this blog post is available as a self-contained main.zig and main-async.zig with no external dependencies other than the Zig 0.16 standard library on GitHub.
A Brief History of I/O In Zig
Zig 0.15.1 - “Writergate”: All existing std.io readers and writers were deprecated in favor of the new std.Io.Reader and std.Io.Writer. These are non-generic structs that hold both a vtable pointer and buffer. The buffer lives in the interface and not in the implementation.
References:
Zig 0.16 - Io Instance and “Juicy Main”: All input and output functionality requires being passed in an Io instance. In addition to this, the classic pub fn main() !void signature is replaced by adding a new parameter to main: std.process.Init or also known as “Juicy Main”.
References:
- https://ziglang.org/download/0.16.0/release-notes.html#IO-as-an-Interface
- https://ziglang.org/download/0.16.0/release-notes.html#Juicy-Main
The Two-Layer Model
One thing that trips up newcomers is that there are two distinct “servers” in the code, however, they operate at different levels of the network stack and it’s worth keeping this in mind moving forward.
- The TCP Server (
std.Io.net) - binds a port, accepts connections, and gives you a raw byte stream. - The HTTP Server (
std.http.Server) - sits on top of that stream and parses it as HTTP/1.1.
Code Explained
const std = @import("std");
const log = std.log.scoped(.server);
const LISTEN_ADDR = "127.0.0.1";
const LISTEN_PORT = 8000;
fn startServer(io: std.Io) !void {
log.info("Listening on http://{s}:{d}", .{ LISTEN_ADDR, LISTEN_PORT });
const addr = std.Io.net.IpAddress.parseIp4(LISTEN_ADDR, LISTEN_PORT) catch unreachable;
// TCP layer: bind the port and accept the raw streams
var server = try addr.listen(io, .{ .reuse_address = true });
defer server.deinit(io);
while (true) {
log.info("Waiting for connection...", .{});
var stream = try server.accept(io);
defer stream.close(io);
log.info("TCP connection established", .{});
// Wrap the raw stream in buffered Io.Reader / Io.Writer
var read_buffer: [1024]u8 = undefined;
var write_buffer: [1024]u8 = undefined;
var reader = stream.reader(io, &read_buffer);
var writer = stream.writer(io, &write_buffer);
// HTTP layer: parse the byte stream at HTTP/1.1
var http_server = std.http.Server.init(&reader.interface, &writer.interface);
var req = try http_server.receiveHead();
log.info("{s} {s}", .{ @tagName(req.head.method), req.head.target });
try req.respond("Hello World!", .{ .status = .ok });
log.info("Response sent, closing connection", .{});
}
}
pub fn main(init: std.process.Init) !void {
log.info("Starting server", .{});
try startServer(init.io);
}
Concurrency and Performance
This server currently can only handle one connection at a time. If we want multiple connections and better performance we can do 4 things:
std.Io.Group- Each accepted connection is handed off tohandleStreamas it’s own async task. This lets the server accept new connections while existing ones are still being served.var group: std.Io.Group = .init; defer group.cancel(io); while (true) { const stream = try server.accept(io); group.async(io, handleStream, .{ io, stream }); } try group.await(io);- Keep-alive connections - The inner
while (true)loop inhandleStreamis the single biggest performance lever. Without it, every request pays the full cost of a TCP handshake. With it,wrkand real browsers can reuse the same connection for many requests. Runningwrkat this stage results in a bunch oferror.HttpConnectionClosignerrors but we can handle it silently by just returning.while (true) { var req = http_server.receiveHead() catch |err| switch (err) { error.HttpConnectionClosing => return, else => return, }; req.respond("Hello World!", .{ .status = .ok }) catch |err| { log.err("failed to respond: {}", .{err}); }; } - Buffer size - Read and write buffers are now set to 4096 bytes. The original 1024-byte buffers are small enough that a request with typical headers can require multiple reads to assemble. 4096 covers the vast majority of real-world requests in a single. Increasing the buffers to 8192 bytes shows minimal performance increases in benchmarks.
- No per-request logging: Logging every request through
log.infois a significant bottleneck under load. Removing it from the hot path while keeping error logging was one of the more impactful changes for performance. If you need per-request logging in a production environment, we would batch log writes.
Benchmark
Command: wrk -t4 -c100 -d10s <url>
- 4 Threads
- 100 Connections
- 10s Duration
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 24.41us 12.36us 3.03ms 77.19%
Req/Sec 150.24k 4.42k 163.87k 71.29%
4528369 requests in 10.10s, 220.25MB read
Requests/sec: 448351.64
Transfer/sec: 21.81MB
For comparison, Caddy serving caddy respond "Hello World!":
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 210.64us 235.88us 5.07ms 86.67%
Req/Sec 111.41k 1.73k 115.95k 75.50%
4478865 requests in 10.10s, 615.08MB read
Requests/sec: 443457.86
Transfer/sec: 60.90MB
~448k vs ~443k req/s is essentially identical. This is ~50 lines of straightforward Zig standard library code matching a production-hardended server.
That said, this benchmark is about as favorable as it gets for a minimal server. A static “Hello World!” response with no routing, no middleware, and no “real” work to do is precisely the scenario where simplicity wins. In any benchmark that resembles real-world usage such as TLS termination, dynamic routing, HTTP/2, or serving static files - Caddy would pull ahead and by a lot. Matching its throughput on a toy benchmark is a fun result, but it says more about how well Zig’s standard library is designed than it does about production readiness. If you’re building something real, use the right tool for the job and Caddy is absolutely that for many use cases.
Why Zig
50 lines where you fully understand every allocation and what every line does is a valuable piece of code. It may not match or replace Caddy or other production-ready software but it’s about building something that you understand from start to finish and you can build upon; be it a reverse-proxy, a load-balancer, or something else.