Networking · lesson 07

HTTP by hand

You have used HTTP a thousand times without seeing it. Strip away the browser and it is a handful of ASCII lines over a TCP connection — bytes you can type yourself and bytes you can parse with strstr.

in progress
14 min

The browser is hiding something

You type a URL and a page appears. Somewhere between those two events a TCP connection opened, bytes went out, bytes came back. You have never seen them. The browser does not show them. curl does not show them unless you ask. So HTTP feels like a thing browsers do, not a thing your program could do.

It is bytes. Specifically, it is ASCII text with \r\n at the end of each line, written into a TCP socket. That is the whole trick. A protocol is an agreement between two programs about what the bytes mean. HTTP is one of the simplest such agreements, and you already have every tool you need to speak it: connect, send_all, recv, and the framing ideas from the last lesson.

The request, byte by byte

Here is a complete HTTP/1.1 request. Every line ends in a carriage return and a line feed — two bytes, 0x0D 0x0A. An empty line marks the end of the headers. After that comes an optional body. This request has none.

bytes
GET / HTTP/1.1\r\n ← request line: method, path, version Host: example.com\r\n ← the only header HTTP/1.1 requires Connection: close\r\n ← "close the socket after the response" \r\n ← empty line = end of headers

In memory that is 62 bytes. The request line is three words separated by single spaces. Headers are Name: value, one per line. The server reads until it sees the four-byte sequence \r\n\r\n, and then it knows the headers are over. That is framing — the delimiter kind from the last lesson, with a four-byte delimiter.

The response, byte by byte

bytes
HTTP/1.1 200 OK\r\n ← status line: version, code, reason Content-Type: text/plain\r\n Content-Length: 13\r\n ← the body is exactly 13 bytes Connection: close\r\n \r\n ← end of headers hello, world\n ← body: 12 chars + newline = 13 bytes

Look at Content-Length: 13. That is a length prefix. It is the same idea as the 4-byte big-endian count you wrote in recv_exact, written in decimal ASCII and placed in a header instead of at the front. The client reads headers until \r\n\r\n, parses the number, then reads exactly 13 more bytes. Without that number the client cannot know where the body ends — TCP will not tell it.

💡
HTTP is two framing schemes stacked. Headers are delimiter-framed (\r\n per line, \r\n\r\n to end). The body is length-framed (Content-Length). Every HTTP parser you have ever used is a loop that finds \r\n\r\n, then a loop that reads N bytes.

A client with printf

Since a request is text, you can build it with snprintf and push it through send_all. Then read the response until the server closes the connection — that is what Connection: close buys you: recv returns 0 when the whole response has arrived, so you do not have to parse Content-Length for this first version.

c
// http_get.c — fd is a TCP socket already connect()ed to host:80 int http_get(int fd, const char *host, const char *path) { char req[512]; int n = snprintf(req, sizeof req, "GET %s HTTP/1.1\r\n" "Host: %s\r\n" "Connection: close\r\n" "\r\n", path, host); if (send_all(fd, req, n) == -1) { perror("send"); return -1; } char buf[4096]; for (;;) { ssize_t got = recv(fd, buf, sizeof buf, 0); if (got == 0) break; // server closed: response complete if (got < 0) { perror("recv"); return -1; } fwrite(buf, 1, got, stdout); // headers and body, raw } return 0; }

Run it against any web server and the first thing printed is HTTP/1.1 200 OK or HTTP/1.1 301 Moved Permanently, followed by headers, a blank line, and HTML. Those bytes were always there. You just never looked at the socket directly.

⚠️
Forgetting the Host header gets you a 400. One IP address serves many sites. The server cannot tell which one you want from the TCP connection alone — the only clue is the Host line. HTTP/1.1 makes it mandatory for exactly that reason.

A 60-line server

The server side is the mirror image. Accept a connection. Read into a buffer until the buffer contains \r\n\r\n. Pull the method and path out of the first line. Write a response with a correct Content-Length. Close. Repeat.

c
#include <stdio.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> // send_all() from the previous lesson static void handle(int c) { char req[8192]; size_t len = 0; // 1. read until the headers are complete while (len < sizeof req - 1) { ssize_t n = recv(c, req + len, sizeof req - 1 - len, 0); if (n <= 0) return; // client went away len += n; req[len] = '\0'; if (strstr(req, "\r\n\r\n")) break; // end of headers seen } // 2. parse the request line: "GET /path HTTP/1.1" char method[8], path[1024]; if (sscanf(req, "%7s %1023s", method, path) != 2) return; // 3. build the body, then the headers that describe it char body[1200]; int blen = snprintf(body, sizeof body, "you asked for %s\n", path); char hdr[256]; int hlen = snprintf(hdr, sizeof hdr, "HTTP/1.1 200 OK\r\n" "Content-Type: text/plain\r\n" "Content-Length: %d\r\n" "Connection: close\r\n" "\r\n", blen); send_all(c, hdr, hlen); send_all(c, body, blen); } int main(void) { int s = socket(AF_INET, SOCK_STREAM, 0); int yes = 1; setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes); struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(8080), .sin_addr.s_addr = htonl(INADDR_ANY) }; if (bind(s, (struct sockaddr *)&a, sizeof a) == -1) { perror("bind"); return 1; } listen(s, 16); for (;;) { int c = accept(s, NULL, NULL); // blocks until a client connects if (c == -1) { perror("accept"); continue; } handle(c); close(c); // this is "Connection: close" } }

Compile it, run it, and point a browser at http://localhost:8080/anything. The browser is satisfied. It has no idea the server is 60 lines of C with no library behind it, because the bytes are right, and the bytes are all the browser ever sees.

Watch the bytes with curl -v

sh
$ curl -v http://localhost:8080/hello * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 > GET /hello HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.5.0 > Accept: */* > < HTTP/1.1 200 OK < Content-Type: text/plain < Content-Length: 21 < Connection: close < you asked for /hello * Closing connection

Lines starting with > are bytes curl sent. Lines starting with < are bytes it received. Each one ended in \r\n on the wire; curl strips them for display. This is the entire conversation.

🚫
A response without Content-Length or Connection: close hangs the client. The client has read the headers and is now waiting for a body. How many bytes? It does not know. It will sit in recv until the server closes the connection or the client gives up. Every "my server works in the browser but curl hangs" bug is one of these two headers missing.

Keep-alive: reusing the connection

Opening a TCP connection costs a handshake — a full round trip before a single byte of HTTP moves. A page with 40 images would pay that 40 times. So HTTP/1.1 defaults to keep-alive: unless someone says Connection: close, the connection stays open and the next request goes down the same socket. That is why Content-Length matters more than it first appears. With the connection staying open, the end of the body is the only way the client knows where one response stops and the next begins. Our server sidesteps this by closing every time; a real one keeps the socket, loops back to step 1, and reads the next request from the same buffer.

✅
Always send a correct Content-Length. Compute the body first, measure it, then write the headers. Never guess the number and never hard-code it. Off by one and the client either hangs waiting for a byte that never comes, or reads the first byte of the next response as the last byte of this one.

Where this goes

Every web API you have called is this: a request line, headers, a blank line, a body — usually JSON. A coordination server for a mesh VPN, the kind that tells peers each other's addresses, is this server plus a JSON parser and a table of peers. Real deployments wrap the socket in TLS so the bytes on the wire are not readable; what that hides and what it does not is the subject of the last lesson in this track.

But first there is a problem with the server above. handle blocks in recv until the client sends a complete request. While it waits, the server is doing nothing else — accept is not being called, and every other client is stuck in the listen backlog. One slow client, or one client that connects and sends nothing, freezes the whole server. Fixing that without a thread per client is the next lesson.

one-line takeaway

HTTP is ASCII lines ending in \r\n over a TCP stream — delimiter-framed headers, a length-framed body — and you can write both sides with snprintf, strstr, and the socket calls you already know.