· 6 min read ·

Building a Web Server in C Is How You Actually Read the HTTP Spec

Source: lobsters

Maurycy Zarzecki’s The web in 1000 lines of C is the kind of project that looks deceptively simple from the outside. A web server in a thousand lines. How hard could it be? Harder than the line count suggests, and more illuminating than most people expect. The value isn’t the server itself. It’s what you’re forced to understand in order to write it.

The POSIX Socket Sequence

Before any HTTP parsing happens, you need a socket. The POSIX API for TCP servers follows a fixed ceremony:

int fd = socket(AF_INET, SOCK_STREAM, 0);
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
listen(fd, SOMAXCONN);

while (1) {
    int client = accept(fd, NULL, NULL);
    handle_request(client);
    close(client);
}

socket() allocates a file descriptor. bind() associates it with an address and port. listen() marks it passive, meaning it will accept incoming connections rather than initiate them. accept() blocks until a client connects and returns a new file descriptor for that specific connection. From that point on, recv() and send() (or read() and write()) operate on bytes, with no concept of messages or requests built in.

This is where the educational payoff starts. Every web framework you’ve used sits on top of exactly this. Flask, Express, Rails: they all bottom out here, at a blocking accept() call on a TCP socket.

HTTP Is a Text Protocol Over a Byte Stream

The mismatch between TCP and HTTP is the central challenge of writing a server from scratch. TCP gives you a stream of bytes. HTTP gives you structured messages. The server has to impose structure on the stream.

An HTTP/1.1 request, as specified in RFC 9112, looks like this:

GET /index.html HTTP/1.1\r\n
Host: example.com\r\n
Accept: text/html\r\n
\r\n

The request line is METHOD SP Request-URI SP HTTP-Version CRLF. Headers follow, each as Name: Value CRLF. A blank line (bare CRLF) terminates the header section. An optional body follows, whose length is determined by the Content-Length header or by Transfer-Encoding: chunked, as defined in RFC 9110.

Parsing this from a socket is not a single recv() call. The kernel might deliver the request in one chunk, or in six. You read into a buffer, scan for \r\n\r\n to find the end of headers, parse what you have, and decide how much body to read based on the headers you just parsed. A 1000-line server will almost certainly handle the common cases: Content-Length for POST bodies, no body for GET. Chunked transfer encoding, where the body arrives in sized chunks with their own framing, is a different parsing problem entirely and is usually omitted from pedagogical implementations.

What the Parsing Actually Involves

The core parsing loop has to do several things correctly:

  • Buffer incoming bytes across multiple recv() calls until the header terminator appears
  • Split the request line on spaces to extract method, URI, and protocol version
  • Split each header line on the first colon to extract name and value
  • Handle leading and trailing whitespace in header values
  • Detect Content-Length and read exactly that many bytes from the body
  • Reject or ignore malformed lines without crashing

None of these are individually hard. Together, in C, with manual memory management and no string library to speak of, they consume most of your line budget. A real implementation like mongoose handles this parsing robustly across thousands of lines, with attention to edge cases that a weekend project will skip.

The classic pedagogical predecessor here is nweb, the IBM developerWorks minimal web server that circulated for years as a teaching example. It made the same tradeoffs: handle GET requests, serve static files, keep the parsing simple, and leave the hard parts as an exercise.

Connection Management

HTTP/1.0 assumed one request per connection. You accept, read the request, write the response, close the socket. Clean.

HTTP/1.1 introduced persistent connections by default. Unless the client sends Connection: close, the server is expected to keep the connection open for additional requests. Implementing this correctly means your handle_request() function becomes a loop: read a request, write a response, check the Connection header, repeat. If you get the loop termination wrong, you either close connections too early (breaking keep-alive clients) or hold them open forever (leaking file descriptors).

A 1000-line server typically handles Connection: close and defaults to closing after each response, which is the safe and simple choice. It means HTTP/1.1 clients work, with the performance characteristics of HTTP/1.0.

Serving Files: MIME Types and the Lookup Table

Once you can parse a GET request and extract the URI, serving static files reduces to opening the file, determining its content type, and writing an HTTP response:

char *body = read_file(path, &body_len);
char *mime = mime_for_extension(path);

char header[512];
snprintf(header, sizeof(header),
    "HTTP/1.1 200 OK\r\n"
    "Content-Type: %s\r\n"
    "Content-Length: %zu\r\n"
    "Connection: close\r\n"
    "\r\n", mime, body_len);

send(client, header, strlen(header), 0);
send(client, body, body_len, 0);

MIME type detection at this level is a switch statement or a small lookup table mapping file extensions to strings. .html maps to text/html; charset=utf-8. .css maps to text/css. Anything unrecognized gets application/octet-stream. This is what every serious server does under the hood, except with a much larger table and, sometimes, content sniffing for files whose extensions are missing or wrong.

The Concurrency Problem

The accept() loop shown above is single-threaded. One request at a time. While handle_request() is running, every other incoming connection waits. For a local development server or a toy project, this is fine. For anything resembling production, it is not.

The classical approaches to concurrency in C network servers are:

  • fork() a child process per accepted connection (the Apache prefork model)
  • Spawn a thread per connection using pthread_create()
  • Use select() or poll() to multiplex multiple connections in a single thread
  • Use epoll() on Linux (or kqueue() on BSD/macOS) for efficient event-driven I/O at scale

Each approach has different resource costs and complexity tradeoffs. fork() is simple but expensive. Threads are cheaper but introduce shared-state hazards. epoll() scales to tens of thousands of connections but requires restructuring the entire server around non-blocking I/O and an event loop. nginx uses an event-driven model built on epoll(); that architecture, not any parsing trick, is what lets it handle the connection counts it handles.

A 1000-line server picks one of the first two options, or stays single-threaded and acknowledges the limitation.

What 1000 Lines Buys You, and What It Doesn’t

A well-written minimal C HTTP server will handle:

  • GET requests for static files
  • Basic POST with Content-Length
  • Correct status codes for 200, 404, and 500
  • Standard response headers
  • A reasonable subset of MIME types

It will not handle:

  • TLS (HTTPS requires a library like OpenSSL or mbedTLS, which is an entire project in itself)
  • HTTP/2 or HTTP/3
  • Chunked transfer encoding in either direction
  • Request pipelining
  • Range requests (Range: bytes=0-1023)
  • Compression (Content-Encoding: gzip)
  • Virtual hosting
  • URL decoding and path normalization done correctly

This is not a criticism of the exercise. The omissions are the point. Writing the server shows you the floor of what web infrastructure does, and the omissions show you the ceiling. When you read that nginx is 300,000 lines of C, the delta between 1000 and 300,000 is now concrete rather than abstract.

Why the Exercise Is Worth Doing

Reading RFC 9110 and RFC 9112 in the abstract is useful. Writing a server that has to comply with them is a different kind of understanding. The parsing challenge forces you to read the spec carefully. The connection management section becomes relevant when your browser hangs. The concurrency question becomes real when you try to load a page with multiple assets.

The POSIX socket API has been stable for decades. The HTTP parsing problem has the same shape whether you’re writing a learning exercise or a production library. Building a minimal server doesn’t make you qualified to deploy one, but it does make the production servers legible in a way they weren’t before. That’s the return on a weekend and a thousand lines of C.

Was this interesting?