Building an HTTP Server from Scratch
Let’s built an HTTP server from scratch using only Zig’s standard library. Just TCP, threads, and the spec. This post goes deep on the four ideas that make it work - parsing requests, building responses, serving static files, and handling connections. I’ll show the important code and link to the full source so you can read the rest yourself.
Source is written in Zig, so I assume you have some knowledge of Zig but it’s not mandatory you can still read thought to understand the concepts. I recommend following along with the code as you read, the explanations here give you the idea, the source gives you the details.
Full source on Codeberg and on GitHub.
HTTP basics
Source - src/Request.zig
Before writing any code, let’s look at what an HTTP request actually is.
Here’s a simple GET request -
GET /search?q=hello HTTP/1.1\r\n
Host: example.com\r\n
Accept: text/html\r\n
\r\n
That \r\n at the end of each line is two actual bytes. \r (0x0D) is carriage return moved the cursor back to the start on old teletypes. \n (0x0A) is line feed moved the cursor down one line. Together they’re the line ending HTTP uses. Every line in an HTTP request ends with \r\n.
The request line
The first line is the request line it has three parts:
GET /search?q=hello HTTP/1.1
| | |
| | |
method uri version
Method - what the client wants to do. GET means retrieve a resource. POST means send data. Others exist (PUT, DELETE, PATCH) but GET and POST cover most cases.
URI - the resource identifier. /search?q=hello has two parts - the path /search and the query string ?q=hello. The query string starts with ? and has key=value pairs separated by &. Our parser captures the raw URI but doesn’t split the query string - you’d need a helper for that.
Version - which HTTP version. HTTP/1.1 is the common one. HTTP/1.0 doesn’t support keep-alive by default. HTTP/2 exists but is binary, not text.
The request line ends with \r\n just like every other line.
The structure is -
[request line]\r\n
[header 1]\r\n
[header 2]\r\n <- assume this is the last header
\r\n
[body if any]
The blank line is just \r\n by itself. So the end of headers is \r\n\r\n the \r\n that ends the last header, plus the blank line’s \r\n.
Here’s our example with each byte visible -
G E T / s e a r c h ? q = h e l l o H T T P / 1 . 1 \r \n
H o s t : e x a m p l e . c o m \r \n
A c c e p t : t e x t / h t m l \r \n
\r \n
Three lines, each ending in \r\n. The blank line marks the boundary between headers and body. For a GET request, there’s no body. For a POST, the body comes right after that blank line.
A POST request with a body
POST /submit HTTP/1.1\r\n
Content-Type: application/json\r\n
Content-Length: 27\r\n
\r\n
{"name": "tom", "age": 30}
Two things to notice. Content-Length: 27 tells the server how many bytes the body is. The body {"name": "tom", "age": 30} is exactly 27 bytes. The server must read exactly 27 bytes. Not more, not less. Read fewer and you’re missing data. Read more and you’re eating into the next request on the same connection. The body starts right after \r\n\r\n no extra separator.
Why not just search for \r\n\r\n?
You might think read all bytes into a string, search for \r\n\r\n. But HTTP comes from a TCP socket stream. You get bytes one at a time, in order, can’t go back. And you don’t want to buffer the entire request before doing anything. You want to process as it arrives.
So you read byte by byte, asking: “have I seen \r\n\r\n yet?”
For this we implement a state machine.
Request parsing
Source - src/Request.zig
Finding the end of headers
We track how far we are through the \r\n\r\n sequence. Four states -
idle - haven't started matching
cr - just saw \r
cr_lf - just saw \r\n
cr_lf_cr - just saw \r\n\r
Here’s the code -
var state: enum { idle, cr, cr_lf, cr_lf_cr } = .idle;
state = switch (state) {
.idle => if (byte == '\r') .cr else .idle,
.cr => if (byte == '\n') .cr_lf else if (byte == '\r') .cr else .idle,
.cr_lf => if (byte == '\r') .cr_lf_cr else if (byte == '\n') .cr_lf else .idle,
.cr_lf_cr => if (byte == '\n') break else if (byte == '\r') .cr else .idle,
};
Let’s trace through what happens with the end of our headers the \r\n\r\n at the boundary -
byte = \r (first one)
state = idle -> byte is \r -> state becomes cr
byte = \n
state = cr -> byte is \n -> state becomes cr_lf
byte = \r (second one)
state = cr_lf -> byte is \r -> state becomes cr_lf_cr
byte = \n (final one)
state = cr_lf_cr -> byte is \n -> break out of loop
Now watch what happens when the sequence breaks. Say we have Host: example.com\r\n at the end of that line, state is cr_lf. The next byte is A (start of Accept) -
byte = A
state = cr_lf -> byte is not \r, not \n -> state becomes idle
We fall back to idle. No progress toward \r\n\r\n yet.
What about Host: value\r\r\n? Two carriage returns -
byte = \r (first)
state = idle -> byte is \r -> state becomes cr
byte = \r (second)
state = cr -> byte is \r -> state stays cr
byte = \n
state = cr -> byte is \n -> state becomes cr_lf
The machine handles this, the second \r doesn’t break anything, it restarts the match. \r\r\n is valid (even if unusual).
The state machine doesn’t buffer anything. It just tracks progress. We store each byte as we read it, and once we break out, we have the complete headers.
Here’s the full loop:
fn readUntilEndOfHead(allocator: std.mem.Allocator, reader: *std.Io.Reader) Error![]u8 {
var buf: [max_head_size]u8 = undefined;
var len: usize = 0;
var state: enum { idle, cr, cr_lf, cr_lf_cr } = .idle;
while (true) {
const byte = reader.takeByte() catch |err| switch (err) {
error.EndOfStream => {
if (len == 0) return error.ReadFailed;
return error.InvalidRequestLine;
},
error.ReadFailed => return error.ReadFailed,
};
if (len >= buf.len) return error.HeaderTooLarge;
buf[len] = byte;
len += 1;
state = switch (state) {
.idle => if (byte == '\r') .cr else .idle,
.cr => if (byte == '\n') .cr_lf else if (byte == '\r') .cr else .idle,
.cr_lf => if (byte == '\r') .cr_lf_cr else if (byte == '\n') .cr_lf else .idle,
.cr_lf_cr => if (byte == '\n') break else if (byte == '\r') .cr else .idle,
};
}
return try allocator.dupe(u8, buf[0..len]);
}
The if (len >= buf.len) return error.HeaderTooLarge line is a safety check. Without it, a malicious client could send huge headers and exhaust memory. We cap it at 8KB.
Splitting headers into key-value pairs
Once we have the raw bytes, we split on \n to get lines, then split each line on : for the header name and value -
var lines = std.mem.splitScalar(u8, raw, '\n');
const request_line = lines.next() orelse return error.InvalidRequestLine;
const trimmed_line = std.mem.trimEnd(u8, request_line, &[_]u8{'\r'});
The trimEnd removes the trailing \r since lines end in \r\n and we split on \n, the \r is still there.
The request line GET /search?q=hello HTTP/1.1 splits on spaces -
var parts = std.mem.splitScalar(u8, trimmed_line, ' ');
const raw_method = parts.next() orelse return error.InvalidRequestLine; // "GET"
const raw_uri = parts.next() orelse return error.InvalidRequestLine; // "/search?q=hello"
const version = parts.next() orelse return error.InvalidRequestLine; // "HTTP/1.1"
Headers split on : -
const colon = std.mem.indexOf(u8, trimmed, ":") orelse return error.InvalidHeader;
const name = std.mem.trim(u8, trimmed[0..colon], " "); // "Host"
const value = std.mem.trim(u8, trimmed[colon + 1 ..], " "); // "example.com"
The trim handles the optional space after the colon Host: example.com and Host:example.com are both valid.
Reading the body
After headers, the body is whatever comes next. But how much? Content-Length tells us -
if (content_length) |cl| {
if (cl > max_body_size) return error.BodyTooLarge;
if (cl > 0) {
const body_buf = try allocator.alloc(u8, cl);
try reader.readSliceAll(body_buf);
body = body_buf;
}
}
We allocate exactly Content-Length bytes and read exactly that many. This is why Content-Length must match the actual body, if the client says Content-Length: 27 but sends 30 bytes, we read 27 and leave 3 bytes in the socket. Those 3 bytes become the start of the next request, causing a parse error.
The full parser is in src/Request.zig.
Response building
Source - src/Response.zig
Now the other direction. We(server) send a response. Same structure as a request -
HTTP/1.1 200 OK\r\n
content-type: text/plain\r\n
content-length: 13\r\n
\r\n
Hello, World!
Same structure - status line, headers, blank line, body. The status line has three parts - version, status code, reason phrase.
The status line
HTTP/1.1 200 OK
HTTP/1.1 is the protocol version, 200 is the status code, OK is the reason phrase.
Here you can find a referece to the list of status code - https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status
Headers
After the status line come the headers, same format as requests -
content-type: text/plain\r\n
content-length: 13\r\n
Header names are lowercase by convention (though the spec says they’re case-insensitive). Format is name: value.
Content-Type tells the client what kind of data the body is (text, JSON, HTML, image). Content-Length is the exact byte count must be exact, same reason as in requests.
The blank line
After the last header, send \r\n a blank line. Same trick as requests, marks the boundary between headers and body. Client reads until \r\n\r\n, then everything after is the body.
The body
The body is whatever bytes you want HTML, JSON, plain text, binary. The only rule: byte count must match Content-Length.
Putting it together in code
Our response builder separates setting up from sending -
resp.setStatus(.ok);
resp.setHeader("content-type", "text/plain");
try resp.send("Hello, World!");
send() does the rest -
pub fn send(self: *Response, body: []const u8) Error!void {
if (self.headers.get("content-length") == null) {
try self.setContentLength(body.len);
}
try self.sendHead();
try self.writer.writeAll(body);
try self.writer.flush();
self.finished = true;
}
If you didn’t set Content-Length, send() sets it from the body length. Then sendHead() writes the status line and all headers -
pub fn sendHead(self: *Response) Error!void {
if (self.headers_sent) return;
try self.writer.print("HTTP/1.1 {d} {s}\r\n", .{
@intFromEnum(self.status),
self.status.phrase(),
});
var it = self.headers.iterator();
while (it.next()) |entry| {
try self.writer.print("{s}: {s}\r\n", .{ entry.key_ptr.*, entry.value_ptr.* });
}
try self.writer.writeAll("\r\n");
self.headers_sent = true;
}
sendHead() is idempotent calling it twice is safe. This matters for streaming responses where you call it explicitly, but send() also calls it internally.
After sendHead(), we write the body, then flush. Flush pushes buffered data to the socket. Without it, data might sit in a write buffer and never reach the client.
Some helper methods (optional)
Instead of always setting status + content-type + send, we have shortcuts -
// These are all just setup + send():
resp.ok("Hello"); // 200, text/plain
resp.html("<h1>Hello</h1>"); // 200, text/html
resp.json("{\"key\": \"value\"}"); // 200, application/json
resp.notFound(); // 404, text/plain
resp.internalError(); // 500, text/plain
resp.redirect("/new-location"); // 302, no body
Each one sets the status and headers, then calls send(). For example -
pub fn notFound(self: *Response) Error!void {
self.setStatus(.not_found);
try self.setHeader("content-type", "text/plain");
try self.send("404 Not Found\n");
}
Streaming responses
For large files, don’t load the whole thing into memory. Send headers first, write chunks, then flush -
try r.sendHead();
var file_buffer: [64 * 1024]u8 = undefined;
var offset: u64 = 0;
while (true) {
const bytes_read = file.readPositional(io, &.{&file_buffer}, offset) catch return;
if (bytes_read == 0) break;
r.writer.writeAll(file_buffer[0..bytes_read]) catch return;
offset += bytes_read;
}
try r.end();
The key, set Content-Length before sendHead(). Once headers are sent, you can’t add more. For files, get the size from file.stat() -
const stat = file.stat(io) catch { ... };
try r.setContentLength(stat.size);
The full response code is in src/Response.zig.
Static file serving
Source - src/Static.zig
Serving a file is resolve the path, figure out the MIME type, stream the file contents.
MIME types
MIME types tell the client what kind of file it’s getting. text/html for HTML, image/png for PNG, application/javascript for JS. Wrong MIME type and the browser won’t render a CSS file as styles it’ll show raw text.
We use a compile-time map -
const mime_types = std.StaticStringMap([]const u8).initComptime(.{
.{ ".html", "text/html; charset=utf-8" },
.{ ".css", "text/css; charset=utf-8" },
.{ ".js", "application/javascript; charset=utf-8" },
.{ ".json", "application/json; charset=utf-8" },
.{ ".png", "image/png" },
.{ ".jpg", "image/jpeg" },
.{ ".svg", "image/svg+xml" },
.{ ".wasm", "application/wasm" },
// ... 26 types total
});
initComptime builds the map when you compile. At runtime, looking up an extension is fast no allocation needed.
Path traversal protection
This is a security essential. Without it, /static/../../etc/passwd could serve files outside your public directory -
pub fn resolvePath(self: *const Static, buf: []u8, path: []const u8) PathError![]u8 {
if (std.mem.indexOf(u8, path, "..") != null) return error.InvalidPath;
return std.fmt.bufPrint(buf, "{s}{s}", .{ self.dir, path }) catch return error.PathTooLong;
}
The .. check is simple, if the path has two dots in a row, reject it. A real server would normalize the path and check each component, but this catches the obvious attacks.
Streaming the file
We stream in 64KB chunks -
var file_buffer: [64 * 1024]u8 = undefined;
var offset: u64 = 0;
while (true) {
const bytes_read = file.readPositional(io, &.{&file_buffer}, offset) catch return;
if (bytes_read == 0) break;
r.writer.writeAll(file_buffer[0..bytes_read]) catch return;
offset += bytes_read;
}
readPositional reads from the file at a specific offset. We loop until done. A 100MB video uses 64KB of memory, not 100MB.
We also set Cache-Control: public, max-age=3600 so browsers cache static files for an hour — no re-requesting the same CSS on every page.
The full static server is in src/Static.zig.
The main loop
Source - src/main.zig
Where everything comes together. Listen for TCP connections, accept one, spawn a thread, handle the request.
while (shutdown_flag.load(.monotonic) == 0) {
const stream = server.accept(io) catch |err| {
if (shutdown_flag.load(.monotonic) != 0) break;
std.log.err("Accept failed: {}", .{err});
continue;
};
_ = total_connections.fetchAdd(1, .monotonic);
_ = active_connections.fetchAdd(1, .monotonic);
var thread = std.Thread.spawn(.{}, handleConnection, .{
io, stream, &r,
}) catch |err| {
std.log.err("Failed to spawn thread: {}", .{err});
stream.close(io);
_ = active_connections.fetchSub(1, .monotonic);
continue;
};
thread.detach();
}
Each connection gets its own thread. detach() lets the OS clean up when it exits we don’t join() because that would block the accept loop.
Thread safety
Multiple threads read/write the connection counters. If two threads both do count += 1 at the same time, you lose an update. This is a race condition -
// WITHOUT atomics - broken
var active_connections: u32 = 0;
// Thread A reads 5, Thread B reads 5
// Both write 6. Should be 7.
active_connections += 1;
std.atomic.Value fixes this. The increment is a single, uninterruptible operation. No locks -
// WITH atomics - safe
var active_connections = std.atomic.Value(u32).init(0);
_ = active_connections.fetchAdd(1, .monotonic); // Thread-safe
_ = active_connections.fetchSub(1, .monotonic); // Thread-safe
shutdown_flag is also atomic. The SIGINT handler sets it to 1 on Ctrl+C, the accept loop checks it each iteration. Graceful shutdown - stop accepting new connections, let existing threads finish.
Keep-alive
HTTP/1.1 keeps connections open by default. After the client reads a response, it can send another request on the same socket. Our handleConnection loops to support this -
while (true) {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
var req = Request.parse(allocator, &reader.interface) catch |err| {
return; // Close on parse error
};
defer req.deinit(allocator);
// ... dispatch, flush response ...
const connection = req.getHeader("connection");
const wants_close = (connection != null and
std.ascii.eqlIgnoreCase(connection.?, "close")) or
std.mem.eql(u8, req.version, "HTTP/1.0");
if (wants_close) return;
}
Each iteration creates a new arena, frees it at the end. Memory from request N doesn’t leak into request N+1. The loop exits when the client sends Connection: close or uses HTTP/1.0.
Why it matters? without keep-alive, every request needs a new TCP connection a three-way handshake each time. With keep-alive, the client reuses the connection. A page loading 20 assets saves 20 handshakes.
How the TCP stream works
The stream wraps a TCP socket. Zig’s Io gives you reader() and writer() that return buffered I/O wrappers. The reader fills a buffer from the socket. The writer batches writes and sends on flush.
Each thread gets its own stack-allocated read/write buffers (8KB each). No heap allocation for I/O.
The full main loop is in src/main.zig.
Router and handlers
Source - src/handler.zig and src/Router.zig
Simple - a list of (method, path, handler) tuples. Dispatch is a linear scan -
pub fn dispatch(self: *const Router, req: *Request, res: *Response) bool {
for (self.routes.items) |route| {
if (std.ascii.eqlIgnoreCase(@tagName(req.method), route.method) and
std.mem.eql(u8, req.path, route.path))
{
route.handler(req, res);
return true;
}
}
return false;
}
No pattern matching, no middleware. Just exact match. Fine for a small server.
Handlers are functions that take a request and response. The echo handler reads a POST body and sends it back -
pub fn echoHandler(req: *Request, resp: *Response) void {
if (req.body) |body| {
resp.setStatus(.ok);
resp.setHeader("content-type", req.contentType() orelse "application/octet-stream") catch return;
resp.send(body) catch return;
} else {
resp.ok("No body provided.\n") catch return;
}
}
For JSON, we use std.json.Stringify handles escaping properly -
var jw: std.json.Stringify = .{ .writer = &out.writer };
jw.beginObject() catch { resp.internalError() catch return; return; };
jw.objectField("server") catch { resp.internalError() catch return; return; };
jw.write("zig-http") catch { resp.internalError() catch return; return; };
jw.endObject() catch { resp.internalError() catch return; return; };
Better than building JSON with bufPrint, std.json escapes ", \, and control characters automatically.
End
That’s it. An HTTP server reads bytes from a socket, parses them into a request, looks up a handler, builds a response, writes it back. The spec is just text formatting. The code is just byte manipulation. The threading is just one thread per connection.
This is an educational project. It doesn’t handle every edge case or try to be production-ready. But the core is parsing works, responses are correct, files get served, connections stay alive.
Full source on Codeberg and on GitHub. Read it, run it, break it.