Anatomy of an HTTP Server
The seven things every HTTP server does between a listening socket and the last byte written, whatever framework sits on top.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What does an HTTP server actually do between a listening socket and the last byte of a response?
"Serve HTTP on port 8080." Something has to turn bytes arriving on a port into a function call, and that function's return value back into bytes.
The framework is the server. app.listen(8080) starts it, and everything after that is routes, handlers and middleware. The rest is plumbing that works.
A request never reaches any handler and produces no application log line, because it died in parsing or was refused before accept. You search the handler code for a bug that is not there.
- A request never reaches any handler and produces no application log line, because it died in parsing or was refused before accept. You search the handler code for a bug that is not there.
- p99 latency triples while every handler's own timing is unchanged: the time is being spent accepting, parsing or writing, and none of your instrumentation is on those steps.
- A large response is "slow" in a way no profiler explains, because the cost is serialization and socket writes to a client on a slow link, not application logic (What Serialization Costs).
- Someone asks "can we stream this?" and the honest answer requires knowing whether your framework buffers the whole response before writing — which is a property of the write step, not of your code.
What is actually happening
- Listen. A socket is bound to an address and port and marked passive. The kernel now queues inbound connections for you (Accepting Connections).
- Accept. Each completed TCP handshake becomes a connected socket — a file descriptor your process owns until it closes it.
- Read and parse. Bytes arrive as a stream with no message boundaries. The parser finds the request line, the headers, and the framing rule that says where the body ends (Parsing HTTP).
- Build a request object. The parsed pieces become the
reqyour handler sees: method, path, headers, and a body that is usually still unread (Request and Response Objects). - Route. Method plus path selects one handler, after the middleware chain has had its turn (How a Route Becomes a Function Call, The Middleware Pipeline).
- Execute. Your code runs. This is the only step most engineers can see, and often not the slowest.
- Serialize and write. The result becomes a status line, headers and a body, written back to the socket — which can block, because the client controls how fast it reads.
- Then the connection is either closed or kept open for the next request (Keep-Alive and Connection Reuse).
The seven steps, and what each one owns
Read this as an ownership table rather than a sequence to memorise. The value is being able to say "that failure belongs to step three" when the symptom arrives, because each step fails in a way the others cannot.
Note how few of these steps your application code appears in. Six of the seven are performed by the runtime and the framework on your behalf, which is exactly why their failures feel like they come from nowhere.
- 11. Listen
Binds an address and port, marks the socket passive, sets the accept backlog.
fails by Port already in use; binding to localhost inside a container so nothing external can reach it.
- 22. Accept
Takes one completed connection off the kernel queue as a new file descriptor.
fails by Backlog full under burst; file-descriptor limit reached; accept loop starved by CPU work.
- 33. Parse
Reads bytes, finds the request line, headers and body framing.
fails by Malformed input; oversized headers; ambiguous framing; a client that sends headers slowly and never finishes.
- 44. Build request
Turns parsed bytes into the
reqobject, usually with the body left unread.fails by Assuming the body is present when it is still a stream; header case and duplication surprises.
- 55. Route
Runs middleware, then matches method and path to one handler.
fails by Precedence collisions; a 404 that is really a route-shape bug (Route Precedence).
- 66. Execute
Runs your code: database, cache, external calls, business rules.
fails by Everything else in this domain.
- 77. Serialize + write
Produces status line, headers and body; writes them to the socket.
fails by Large payload CPU cost; slow client; connection already closed; headers sent twice.
Then: close, or keep the connection open for the next request. That choice is step eight and it changes the cost of steps one through three for every subsequent request.
The same server, without the framework
Written out, the loop is unremarkable — and that is the point. Everything a framework provides sits inside step six of this program.
Notice what the raw version forces you to say out loud: the body is a stream you have to consume, the response headers are strings you have to write before the body, and the connection is a resource you have to decide the fate of.
1import { createServer } from 'node:http'2 3// Steps 2-4 happen inside createServer: it accepts connections,4// parses HTTP, and calls you with a request object whose body5// has NOT been read yet.6const server = createServer((req, res) => {7 // Step 5: routing is two string comparisons here.8 if (req.method !== 'POST' || req.url !== '/orders') {9 res.writeHead(404, { 'content-type': 'application/json' })10 res.end('{"error":"not_found"}')11 return12 }13 14 // Step 4, continued: the body is a stream. Nothing has buffered it.15 const chunks: Buffer[] = []16 let size = 017 req.on('data', (c: Buffer) => {18 size += c.length19 if (size > 1_000_000) {20 res.writeHead(413).end() // limit BEFORE the memory is spent21 req.destroy()22 return23 }24 chunks.push(c)25 })26 27 req.on('end', () => {28 // Step 6: your code. The only part a framework tutorial covers.29 const order = JSON.parse(Buffer.concat(chunks).toString('utf8'))30 31 // Step 7: serialize, then write. writeHead is the point of no return.32 const body = JSON.stringify({ id: 'ord_1', total: order.total })33 res.writeHead(201, {34 'content-type': 'application/json',35 'content-length': Buffer.byteLength(body),36 })37 res.end(body)38 })39})40 41// Step 1: bind and listen. The kernel starts queueing connections now.42server.listen(8080)The body limit is enforced while reading, not after. A check on req.body.length in a framework handler runs after the framework already put those bytes in memory — which is the wrong side of the resource you were protecting.
Which step is the slow one?
When latency moves, the useful first question is not "which endpoint" but "which step". These four signals separate them without a profiler, and they are cheap enough to have permanently.
The pattern to internalise: handler time is a subset of request time, and request time is a subset of what the client experiences. Every gap between those three has a specific owner.
| Symptom | Likely step | What confirms it |
|---|---|---|
| Handler fast, request slow, all endpoints affected | Accept / runtime scheduling | Runtime saturation signal — event-loop lag, busy workers, full thread pool (Blocking the Event Loop) |
| Handler fast, request slow, only large responses | 7 — serialize + write | Response size correlates with duration; CPU profile shows encoding |
| Handler fast, request slow, only large uploads | 3/4 — read body | Duration correlates with request size and client network, not with server CPU |
| Request never appears in application logs | 2 or 3 — accept or parse | Proxy logs show the request; the server shows a 400 or nothing |
| Slow only for some clients, same payloads | 7 — write to a slow reader | Duration correlates with client, not with content (Request Bodies and Streaming) |
| Everything slow at once, unrelated endpoints together | Runtime, not any step | Runtime concurrency model is saturated (Backend Runtime Models) |
How to build it
Most important first.
- Learn where your framework puts each step, especially parse and write. That tells you which failures will and will not appear in your handler logs.
- Instrument the boundary, not only the handler: time from accept to first byte read, and from handler return to last byte written, are separate numbers with separate causes.
- Set limits at the parse step — header size, header count, body size, request line length — because that is the only step that runs before you know who is calling (Transport Validation).
- Treat the write step as I/O that can be slow. A response is not delivered when your function returns.
- Keep the accept path free of application work. Anything expensive between accept and read shows up as a queue, not as latency in a handler (Accepting Connections).
What can go wrong
- The accept queue fills during a burst and the kernel drops or refuses connections; clients see connection resets with no server-side error (Accepting Connections).
- A malformed request is rejected by the parser with a 400 that your access log may not even record, depending on where logging is installed.
- File descriptors exhausted: the process cannot accept, cannot open a database connection and cannot read a file, all at once, and the error messages point in three directions.
- The handler finishes and the response write blocks because the client stopped reading, holding memory and a descriptor for a client that has gone (Request Bodies and Streaming).
- Your own mitigation fails: a body-size limit applied after the body has been buffered into memory has already cost you the memory it was supposed to protect.
- Accept and handler execution overlap: new connections keep arriving while existing requests run, so any shared resource touched during a handler is contended (Backend Races).
- A client can close the connection while your handler is mid-flight, so the write step may find a socket nobody is reading — after the database write has already committed (Idempotency in Backends).
- Every limit that stops a resource-exhaustion attack lives in the parse step: maximum header size, maximum header count, maximum URL length, maximum body size, and a header-read timeout.
- Anything the parser accepts, your application will be handed. Duplicate headers, absurd
Content-Lengthvalues and non-ASCII in a header name are decisions the parser makes on your behalf (Parsing HTTP). - The write step can leak: a stack trace serialized into a 500 body tells an attacker your framework, version and file layout (Not Leaking Your Internals).
- "
app.listen()is where the server starts." It is where the listening socket is created. The server is the loop that accepts, parses and writes around your handler. - "If my handler is fast, my endpoint is fast." Handler duration excludes accept queueing, parsing, serialization and the socket write — often the majority of a slow request.
- "HTTP is a request/response protocol, so the server handles one thing at a time." A server handles many connections concurrently; how it does so is the runtime's concurrency model (Backend Runtime Models).
Operating it
- Access-log every request at the outermost layer possible, with status and duration. Requests that die in parsing are exactly the ones a handler-level logger cannot see.
- Track accepted connections, active connections and requests per connection separately. Connections and requests are different counters, and confusing them makes keep-alive behaviour invisible.
- Compare handler duration with total request duration. A widening gap is parse or write, not your code (Why Is My API Slow?).
- Watch open file descriptors as a first-class process metric; it is the resource that limits all of accept, database and disk simultaneously.
- At 10x, the accept and parse steps still cost roughly the same per request, so their share of CPU becomes visible where it was noise. Connection reuse moves from a nicety to a requirement (Keep-Alive and Connection Reuse).
- At 100x, the per-connection memory cost — read buffer, write buffer, kernel socket buffers — becomes a capacity number you have to compute, not a detail.
- Nothing about the seven steps changes. What changes is which one you are paying for, which is why knowing they exist is the scaling skill.
- Understanding the layer beneath the framework costs time that could be spent shipping endpoints. It pays back the first time the answer is not in the handler.
- Instrumenting accept and write adds metrics, cardinality and code on the hot path. Start with two numbers — total duration and handler duration — and go deeper only when they disagree.
- Writing your own HTTP server teaches all of this and is almost always the wrong production choice: correct HTTP parsing, including the security-relevant edge cases, is a large body of work you would be re-deriving.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe seven steps hold for any HTTP server in any language: they follow from the socket API and the protocol, not from a framework.
- PROTOCOL-SPECIFICHTTP/1.1 carries one request at a time per connection, so "connection" and "in-flight request" are nearly the same thing. HTTP/2 multiplexes many streams over one connection, so accept count stops predicting concurrency and per-stream flow control becomes a separate limit; HTTP/3 moves that onto QUIC over UDP, where the connection is not a TCP socket at all.
- FRAMEWORK-SPECIFICWhere the steps live differs: Node's
httpmodule parses in C++ and hands your JavaScript a stream, so the parse step is invisible in a CPU profile of your code; a Python WSGI app is handed an already-parsed environ dict by Gunicorn or uWSGI, so the parse step happens in a different process's accounting entirely.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.