An event-loop ring threading through numbered file-descriptor slots; one active socket glows amber.
SystemsJune 9, 20269 min read

What writing an HTTP server taught me about frameworks

A web server is just a loop. One poll() call, a readiness list, and a rule: never block. This is what I learned building an HTTP server in C++ from raw sockets up — no libraries, no shortcuts.

I built an HTTP server in C++ from scratch — no HTTP libraries. Not "no fancy frameworks." No libraries. Just raw syscalls, a poll() loop, and a parser I had to write myself. The project is called serveme, and it lives at github.com/shinraxtensei/serveme. This post is everything I wish someone had told me before I started.

The central insight is unglamorous: a web server is a loop. One loop. It asks the kernel which file descriptors are ready, services each one, and goes back to waiting. That's it. Every framework — Express, Nginx, Kestrel — is this loop, dressed in a nicer coat.

Everything is a file descriptor

The Unix model is elegant to the point of violence: a socket is just a file descriptor. The same read() / write() calls that work on files work on sockets. The kernel does not care. A TCP connection is just bytes flowing through a numbered slot in the process's open-file table.

When you call socket(), you get back an integer. That integer IS the socket. Everything else — binding, listening, accepting — is just setting properties on that integer and telling the kernel what you want to do with it.

socket_lifecycle.cppC++
// The entire socket lifecycle, start to finish
int sockfd = socket(AF_INET, SOCK_STREAM, 0);  // get an fd
//  AF_INET  = IPv4
//  SOCK_STREAM = TCP (reliable, ordered bytes)
//  0 = default protocol (TCP for SOCK_STREAM)

int reuse = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
// SO_REUSEADDR: don't make us wait 2 min after restart (TIME_WAIT state)

fcntl(sockfd, F_SETFL, O_NONBLOCK);
// Critical: never block waiting. More on this in a moment.

sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;  // listen on all interfaces
addr.sin_port = htons(8080);        // htons: host byte order -> network byte order
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));

listen(sockfd, 100);  // kernel queues up to 100 pending connections for us

// Now the server socket sits there. When a client connects:
sockaddr_in client_addr;
socklen_t addrlen = sizeof(client_addr);
int clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &addrlen);
// clientfd is a brand new fd — one per connected client
htons() is not optional

Network protocols define big-endian byte order. Your CPU is almost certainly little-endian. htons() (host-to-network short) converts your port number from CPU order to wire order. Skip it and port 8080 (0x1F90) becomes port 36895 (0x901F). You will waste an hour before you realise.

The blocking problem — why one slow client freezes everything

Here is the naive approach: accept a client, read their request, send a response, repeat. This works for exactly one client at a time. As soon as a second browser tab connects, it waits — in the kernel's queue — until you finish with the first one. This is what blocking I/O means.

Calling recv(fd, buf, len, 0) on a socket that has no data yet will sleep your process until data arrives. The OS suspends you, runs something else, and wakes you when bytes show up. That's fine for one client. For a server handling thousands, it's death. You can't be asleep for one client while another is trying to connect.

The traditional fix was threads: one thread per connection. But threads are expensive — each needs its own stack (~8 MB), scheduling overhead, and locking hell for shared state. Nginx famously abandoned that model and went to a single-threaded event loop. So did Node.js. So did serveme.

Multiplexing: asking the kernel "who is ready?"

The solution to blocking is I/O multiplexing: instead of blocking on a single fd, you hand the kernel a list of fds and say "tell me which of these have data, without blocking on any of them." The kernel does the waiting. You do the work.

There are three main syscalls for this on Linux/Unix: select(), poll(), and epoll(). select() is ancient (limited to 1024 fds, bitmask API). epoll() is modern Linux-only (O(1) ready-set, ideal for thousands of connections). poll() is the middle ground — a cleaner API than select(), portable, and exactly what serveme uses.

poll_api.cppC++
// The poll() API
struct pollfd {
    int   fd;       // the file descriptor to watch
    short events;   // what events you care about (POLLIN, POLLOUT)
    short revents;  // what events actually happened (filled by kernel)
};

// You give poll() an array of these, and a timeout.
int ret = poll(fds, nfds, timeout_ms);
// ret = number of fds that have events ready
// ret = 0 on timeout, -1 on error

// Flags:
// POLLIN  = data is available to read (or new connection on a server socket)
// POLLOUT = socket can accept writes without blocking
// POLLHUP = peer closed the connection
// POLLERR = error on the fd
poll() vs epoll(): why it matters at scale

poll() has O(n) overhead: every call scans the entire array even if only one fd is ready. With 10,000 connections, that's 10,000 checks per wakeup. epoll() maintains a kernel-side ready list — wakeup cost is O(k) where k is the number of ready fds. For serveme's scale, poll() is fine. For Nginx handling 50k connections, epoll() is why it exists.

The actual loop — and it is exactly what you think

The poll() loop: one array of listening and client sockets; the kernel returns which are ready, then the server accepts or reads.
One array of sockets; the kernel hands back who is ready.

Here is the core of serveme, the real handleConnections() function stripped to its skeleton. This is the entire server:

core.cppC++
void Core::handleConnections() {
    // Add all server (listening) sockets to the poll array first
    for (auto& sock : serverSockets) {
        pollfd pfd;
        pfd.fd = sock->get_sockfd();
        pfd.events = POLLIN;   // we want to know when a connection arrives
        pfd.revents = 0;
        pollFds.push_back(pfd);
    }

    while (true) {                                  // <-- the infinite loop
        int ret = poll(pollFds.data(), pollFds.size(), 60);
        if (ret < 0) throw std::runtime_error("poll() failed");

        for (size_t i = 0; i < pollFds.size(); i++) {

            if (pollFds[i].revents & POLLIN) {

                if (check_servers_socket(pollFds[i].fd) != -1) {
                    // A new TCP connection arrived on a listening socket
                    // accept() completes the handshake, gives us a new client fd
                    Client* client = new Client(serverSockets[...]);
                    fcntl(client->fd, F_SETFL, O_NONBLOCK);  // must be non-blocking
                    pollFds.push_back(client->pollfd_);       // add to the watch list
                    map_clients[client->fd] = client;

                } else {
                    // An existing client has data ready to read
                    map_clients[pollFds[i].fd]->handleRequest();
                }
            }

            if (pollFds[i].revents & POLLHUP) {
                // Client closed the connection
                removeClient(*map_clients[pollFds[i].fd]);
            }
        }
    }
}
Server FDs and client FDs live in the same array

This is the elegance of the model. pollFds contains the listening socket(s) AND all connected client sockets in one flat array. POLLIN on a listening fd means "new connection." POLLIN on a client fd means "request bytes are ready." Same event, different meaning depending on which fd triggered it. The loop doesn't care — it just dispatches.

Non-blocking I/O — why O_NONBLOCK is non-negotiable

Poll tells you a fd is ready. But "ready" means "at least 1 byte is available." If you call recv() and ask for 4096 bytes, only 200 may have arrived. You'll get 200 and recv() returns immediately. Good. But what if you then call recv() again to get the rest? Without O_NONBLOCK, you block — waiting for 3896 more bytes while every other client in your poll array starves.

With O_NONBLOCK set via fcntl(fd, F_SETFL, O_NONBLOCK), the behavior changes: if no data is available, recv() returns -1 with errno == EAGAIN (or EWOULDBLOCK — same thing). That is not an error. It means "nothing here yet, come back later." You add the fd to your poll list, return control to the loop, and wait for the next POLLIN event.

nonblocking.cppC++
// In SocketWrapper constructor — always set non-blocking
SocketWrapper::SocketWrapper(int domain, int type, int protocol) {
    sockfd_ = socket(domain, type, protocol);
    int reuse = 1;
    setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
    fcntl(sockfd_, F_SETFL, O_NONBLOCK);  // ← non-negotiable
}

// And again when we accept() a new client fd
Client::Client(SocketWrapper* sock) {
    fd = sock->accept(*addr);      // get the client fd
    fcntl(fd, F_SETFL, O_NONBLOCK); // immediately make it non-blocking
    pollfd_.fd = fd;
    pollfd_.events = POLLIN;
}

Parsing is a state machine, not a string split

HTTP request parser state machine: START to first line to headers to body, branching into chunked and multipart.
Bytes in, request out — parsing is a state machine.

HTTP/1.1 requests arrive as raw bytes. There's no framing — no length prefix, no magic separator. You receive whatever the kernel decided to buffer, which might be half a header, two full headers, or a header and the start of a body. You have to reassemble them yourself.

The naive approach — split("\r\n") — breaks immediately on partial reads. The correct approach is a state machine: each byte you receive advances a state, and you only act when a state completes.

request_state_machine.cppC++
// States in serveme's request parser
enum Stat {                       // the real enum name in inc/client.hpp
    START         = 1 << 0,       // skip leading CRLFs
    FIRSTLINE     = 1 << 1,       // "GET /path HTTP/1.1"
    HEADERS       = 1 << 2,       // "Key: Value" lines
    DONE          = 1 << 4,       // headers complete

    // chunked transfer sub-states
    CHUNKED_START = 1 << 5,
    CHUNKED_SIZE  = 1 << 6,       // hex chunk length
    CHUNKED_DATA  = 1 << 7,       // chunk bytes

    // multipart/form-data sub-states
    MULTI_PART_START    = 1 << 8,
    MULTI_PART_BOUNDARY = 1 << 9,
    MULTI_PART_HEADERS  = 1 << 10,
    MULTI_PART_DATA     = 1 << 11,
    END                 = 1 << 12,

    // BODY isn't a single state — it's a MASK: "are we anywhere in the body?"
    BODY = CHUNKED_START | CHUNKED_SIZE | CHUNKED_DATA
         | MULTI_PART_START | MULTI_PART_BOUNDARY
         | MULTI_PART_HEADERS | MULTI_PART_DATA | END,
};

// The parser reads one byte at a time from the socket,
// accumulates into a line buffer, and advances state on CRLF:
void Client::handleRequest() {
    char buf[1];
    int ret = recv(fd, buf, 1, 0);  // one byte at a time — intentional
    request->line += buf[0];

    if (request->line contains "\r\n") {
        if (state == FIRSTLINE)  { ParseFirstLine(line); state = HEADERS; }
        else if (state == HEADERS) { ParseHeaders(line);                  }
        line = "";
    }
    if (request->buffer contains "\r\n\r\n") {
        state = BODY;   // blank line = end of headers
    }
}

The first line gives you method, URL, and HTTP version. Each header line is a Key: Value pair. A blank line (\r\n\r\n) marks end of headers. Then — depending on Content-Length or Transfer-Encoding: chunked — you may need to read a body. Each variant has its own state machine nested inside the outer one.

Chunked transfer encoding is a state machine inside a state machine

When Transfer-Encoding: chunked, the body isn't a flat blob. Each chunk is prefixed with its length in hex, followed by CRLF, then the data, then another CRLF. "3a\r\nHello...\r\n0\r\n\r\n" means: 58 bytes of data, then a zero-length chunk marking the end. You need to parse hex, count bytes, and handle partial chunks across multiple recv() calls.

The config file is a grammar

One of the less-obvious parts of the project: you have to write your own config parser. Serveme uses a Nginx-inspired format. The grammar has three nesting levels: httpserverlocation, each with its own set of directives.

servme.confPlain
http {
    client_max_body_size  200000000;  # bytes

    server {
        server_name  mysite;
        listen       8080;            # or ip:port
        root         /www;
        error_page   404 404.html;

        location / {
            allowed_methods  GET POST;
            autoindex        on;
        }

        location \.py$ {             # matched by suffix string, not real regex
            fastcgi_pass  /usr/bin/python3;  # CGI handler
        }
    }

    server {
        server_name  upload;
        listen       8989;
        root         /upload;
        location / {
            allowed_methods  GET DELETE;
        }
    }
}

Parsing this requires a proper lexer + parser pair. The lexer tokenises the stream (identifiers, braces, semicolons). The parser builds a tree of HttpServer[]Location[] objects, each with a directive map. Serveme even has a -d flag that generates a Graphviz .dot file so you can visualise the config tree — useful for debugging nested location blocks.

Virtual hosting — one IP, many servers

A single serveme process can serve multiple domains on different ports, or even the same port with different server_name values. The decision of which server block handles a request happens on the Host: header, not on the IP.

virtual_host.cppC++
void Client::selectServer() {
    // Step 1: find all server blocks listening on this port
    std::vector<Server> candidates;
    for (auto& s : Servme::getCore()->get_http()->servers) {
        if (s.ipPort.second == socket->get_listenPair().second)
            candidates.push_back(s);
    }

    // Step 2: match server_name to Host header
    for (auto& s : candidates) {
        if (s.server_name == request->host) {
            server = new Server(s);
            return;
        }
    }

    // Step 3: fall back to first matching server (Nginx behaviour)
    server = new Server(candidates[0]);
}

The Host header arrives during the HEADERS parsing phase. As soon as we see it, we call selectServer() — so every subsequent decision (root path, allowed methods, max body size, error pages) is scoped to the correct server block.

MIME types — the boring detail that breaks everything

When you serve a file, the browser needs to know what it is. The Content-Type response header tells it. Serving style.css without Content-Type: text/css means the browser will refuse to apply it. Serving script.js as text/plain means it won't execute.

Serveme loads a mime.types file at startup — a simple two-column table mapping extensions to MIME strings — into a std::map<string, string>. When building a response, we look up the file extension in that map and set the header. No library, no database. Just a flat file and a lookup.

Path traversal: a stack and a rule

The moment you map a URL onto a file on disk, you inherit a classic exploit: GET /../../etc/passwd — a request that tries to walk out of your web root. Serveme defuses it before any path matching with a small stack-based normaliser.

responeUtilities.cppC++
std::string normalizePath(std::string path) {
    std::stack<std::string> out;
    // split the path on '/', then fold each segment:
    //   "."   -> skip            (current dir, no-op)
    //   ".."  -> out.pop()       (go up) — but ONLY if out isn't empty
    //   else  -> out.push(seg)   (descend)
    // A ".." against an empty stack does nothing: you can never climb above root.
    ...
}

Every request URL runs through this before it touches the filesystem, and the invariant is one line: a ".." can never pop above the root. No regex, no blocklist of "bad" strings to outsmart — just a stack and a rule, which is far harder to trick than string-matching for ../.

CGI — how dynamic content works without a framework

The fastcgi_pass directive points at an interpreter (/usr/bin/python3, /usr/bin/ruby). When a matching URL is requested, the server fork()s a child process, exec()s the interpreter on the script, wires stdin/stdout via pipes, and reads the response back. The child outputs a valid HTTP response; the server forwards it to the client.

This is the original CGI model — no persistent workers, no socket pools. One request, one process. Expensive, but it's the simplest model there is, and it makes the mechanism completely transparent. You see exactly how $_POST data ends up in a Python script: as environment variables and stdin bytes.

The honest part: the writes are naive

I keep saying "never block" — and serveme's read path holds that line: non-blocking sockets, one poll(), a byte at a time. Its write path is where the toy shows. Responses go out in a loop that tracks how far it got (sendPos), but a send() returning -1 is treated as fatal:

get.cppC++
int sent = send(client_fd, responseStr.c_str(), responseStr.length(), 0);
if (sent == -1 || sent == 0)
    throw std::runtime_error(E500);  // <-- treats EAGAIN as a 500
sendPos += sent;

On a non-blocking socket a full kernel send buffer makes send() return -1 / EAGAIN — which is not an error. It means "the pipe is full, come back when it drains." The correct move is to buffer the unsent tail, flip the fd to POLLOUT, and flush when poll() reports writability. Serveme doesn't — and that gap is the line between a from-scratch webserv and Nginx. Knowing exactly where that line sits is half the reason the project is worth doing.

What every framework is quietly doing

Before this project, when something went wrong in Express or NestJS I would scan Stack Overflow for the symptom. After it, I scan the syscall layer first. The symptom is almost always one of these:

It's layers, all the way up

Once you've seen the floor, the whole tower makes sense. serveme's loop — accept, parse, route, respond — is the bottom layer of every web framework you've ever used. They don't replace it; they stack conveniences on top of it:

Every one of those is sugar over the same four moves: a descriptor became ready, read the bytes, decide, write the bytes back. A Servlet is handleRequest() with a lifecycle bolted on. DI is a tidy way to pass that handler its dependencies. Routing is a std::map from paths to functions. Strip every abstraction away and you land exactly where serveme starts — at poll() and recv().

I built it without HTTP libraries precisely because using them hides the machinery. You don't understand Transfer-Encoding: chunked until you've written a hex-to-size state machine at 2 a.m. You don't understand backpressure until send() returns EAGAIN and you have to figure out what to do with the bytes you couldn't flush. The constraint is the lesson.

← All field notes