Most server tutorials start with Express, Fastify, or some other framework that absorbs all the interesting decisions. I wanted to understand what happens at the socket level — what a byte stream actually looks like when an HTTP client connects, and what the server needs to do to turn that into a structured request.
So I built one from scratch in Zig. No libuv, no framework, no HTTP library. Just raw POSIX sockets and Linux epoll.
Step 1: Get a Socket
The starting point is socket(AF_INET, SOCK_STREAM, 0) — that's a TCP socket over IPv4. Then setsockopt(SO_REUSEADDR) so you can restart the server without waiting for the OS to release the port. Then bind() and listen().
At this point you have a passive socket that the kernel will accept connections on. But you're not actually doing any I/O yet. You have a file descriptor and a promise.
Step 2: epoll
The naive approach — one thread per connection — breaks at scale. epoll lets a single thread watch thousands of file descriptors and get notified only when one has data to read. You create an epoll instance with epoll_create1(), register file descriptors with epoll_ctl(), and block on epoll_wait() until something happens.
The key config choice is edge-triggered vs level-triggered mode. Level-triggered (default) notifies you continuously while data is available. Edge-triggered (EPOLLET) notifies once per state change. Edge-triggered is more efficient but requires you to read until you get EAGAIN — otherwise you'll miss data on the next event.
// Register server socket with epoll
var ev = linux.epoll_event{
.events = linux.EPOLL.IN,
.data = .{ .fd = server_fd },
};
_ = linux.epoll_ctl(epoll_fd, linux.EPOLL.CTL_ADD, server_fd, &ev);
Step 3: Parse HTTP
HTTP/1.1 requests look like this on the wire:
GET /path HTTP/1.1\r\n
Host: localhost:8080\r\n
User-Agent: curl/7.88\r\n
\r\n
The request line and headers are separated by \r\n. The headers section ends with a blank line (\r\n\r\n). My parser is a state machine that scans bytes looking for these boundaries — no regex, just pointer arithmetic and comparisons.
What I Learned
libuv solves real problems. Event loop integration with platform-specific APIs, handle lifecycle management, graceful shutdown — none of this is trivial. Building a toy server shows you what the framework is doing; it doesn't mean frameworks are unnecessary.
Zig's explicit error handling made the socket code surprisingly readable. Every syscall that can fail returns an error union, and you have to decide at every call site what to do with a failure. It enforces clarity about what the failure modes are.
See the project: Zig Server project page →