Building an HTTP Server

Everything in this module has been building to a single, satisfying payoff: you now know enough to build a web server from raw sockets — the thing that sits behind every URL you have ever visited. And the wonderful secret, once you strip away the frameworks, is how small the core idea is. An HTTP server accepts a TCP connection, reads some text off it, figures out what the client asked for, writes some text back, and decides whether to keep the line open. That's it. HTTP is, at heart, a text protocol over a byte stream — and you have already met every hard part.

This is the capstone. We will assemble the pieces you now hold — the socket lifecycle, framing, and a concurrency choice — into one working request/response engine, and write a real (runnable) HTTP parser and router. Along the way you'll see precisely where the classic security and correctness bugs hide.

The anatomy of an HTTP message

HTTP's genius is that a request and a response have the same three-part shape, all in plain text with \r\n (carriage-return + line-feed) line endings:

  1. A start line. For a request: GET /hello?name=alice HTTP/1.1 — method, target, version. For a response: HTTP/1.1 200 OK — version, status code, reason phrase.
  2. A block of headers, one Name: value per line (Host: example.com, Content-Type: text/plain, Content-Length: 12).
  3. A blank line (\r\n\r\n) — and then, optionally, the body.

Look at how HTTP frames itself, because it is the exact combination from the framing page. The header block is delimiter-framed: it ends at the first blank line (\r\n\r\n). The body is length-prefixed: the Content-Length header tells you exactly how many body bytes follow. So parsing an HTTP message means: read until you see the blank line (you now have all the headers), read Content-Length, then read exactly that many more bytes for the body. Both framing strategies, in one message.

The request lifecycle

Here is the whole loop a server runs per connection. Trace it once and every web server you ever touch is a variation on it.

Two subtleties, both familiar. Reading a whole request is a deframing problem — one read() may deliver half a request or several glued together, so you buffer and re-check. Keep-alive means one TCP connection carries many requests in sequence (HTTP/1.1's default), so after writing a response you loop back rather than closing — saving a handshake per request.

The centrepiece: a real HTTP parser and router

Now the payoff — a genuinely working HTTP engine. It parses a raw request string exactly as it arrives on the wire (with real \r\n line endings), splits headers from body at the blank line, routes on method + path, and produces a correct raw response string. Pure string logic — no sockets — so it runs right here.

// ---- PARSE a raw HTTP request (the bytes off the socket) into a structured object ---- function parseRequest(raw) { const sep = raw.indexOf("\r\n\r\n"); // the blank line: headers END here if (sep === -1) return { partial: true }; // no blank line yet -> need MORE bytes const head = raw.slice(0, sep); const rawBody = raw.slice(sep + 4); const lines = head.split("\r\n"); const [method, target, version] = lines[0].split(" "); const headers = {}; for (let i = 1; i < lines.length; i++) { const c = lines[i].indexOf(":"); headers[lines[i].slice(0, c).trim().toLowerCase()] = lines[i].slice(c + 1).trim(); } // Body is length-prefixed by Content-Length. If we don't have it all yet, wait. const need = Number(headers["content-length"] || 0); if (rawBody.length < need) return { partial: true }; const qi = target.indexOf("?"); const path = qi === -1 ? target : target.slice(0, qi); return { method, path, version, headers, body: rawBody.slice(0, need) }; } // ---- BUILD a raw HTTP response string (status line + headers + blank line + body) ---- function buildResponse(status, reason, body, extra) { const headers = Object.assign( { "Content-Type": "text/plain", "Content-Length": body.length }, extra || {}); let out = `HTTP/1.1 ${status} ${reason}\r\n`; for (const k in headers) out += `${k}: ${headers[k]}\r\n`; return out + "\r\n" + body; // the blank line, then the body } // ---- ROUTE: a tiny table of "METHOD path" -> handler(req) ---- const routes = { "GET /": (r) => buildResponse(200, "OK", "Welcome to the Primer HTTP server\n"), "GET /hello": (r) => buildResponse(200, "OK", "Hello, HTTP!\n"), "POST /echo": (r) => buildResponse(200, "OK", `You said: ${r.body}\n`), }; function serve(raw) { const req = parseRequest(raw); if (req.partial) return "(request incomplete — read more bytes)"; const handler = routes[`${req.method} ${req.path}`]; return handler ? handler(req) : buildResponse(404, "Not Found", `No route for ${req.path}\n`); } // ---- Drive it with real raw requests ---- const getReq = "GET /hello HTTP/1.1\r\nHost: primer.dev\r\n\r\n"; const postReq = "POST /echo HTTP/1.1\r\nHost: primer.dev\r\nContent-Length: 5\r\n\r\nmilk!"; const badReq = "GET /nope HTTP/1.1\r\nHost: primer.dev\r\n\r\n"; const show = (label, raw) => { console.log(`=== ${label} ===`); console.log(serve(raw).replace(/\r\n/g, "\\r\\n\n")); // show CRLFs visibly console.log(""); }; show("GET /hello", getReq); show("POST /echo (Content-Length body)", postReq); show("GET /nope (unmatched -> 404)", badReq);

Read the output as bytes: each response is a status line, some headers, a blank line (\r\n\r\n), then the body — and Content-Length tells the client exactly how many body bytes to expect, so it knows when the response ends without closing the connection. That is a real, if tiny, web server.

Choosing how to serve, and where it breaks

A real server wraps that engine in a concurrency model. The choices you met all apply: a thread/worker pool per connection is simple and common; a single-threaded event loop (nginx, Node) scales to enormous connection counts; a pre-forked process pool (Apache, Gunicorn) trades memory for isolation. HTTP itself is agnostic — the request/response engine above is the same in all of them.

But the engine above is naïve, and a server exposed to the open internet meets input that is not merely well-formed test data. This is where beginners' servers get exploited or fall over.

A production HTTP server faces malformed, partial, and hostile requests as a matter of routine, and the parser above handles none of them safely. The three classic traps:

The one-line moral of the whole module: a byte stream from the network is adversarial input. Frame it carefully, bound it, time it out, and validate every field before you believe it.

HTTP/1.1 is gloriously human-readable — you can literally telnet to port 80, type GET / HTTP/1.1, and read the reply. That debuggability is a big reason it conquered the world. But text has costs (parsing ambiguity, header repetition, head-of-line blocking), so HTTP/2 re-encoded the very same semantics as a binary, multiplexed, header-compressed framing — and HTTP/3 moved it all onto UDP via QUIC. The meaning (methods, paths, headers, status codes) is unchanged since the 1990s; only the framing on the wire got faster. Learn the text version and you understand all three.