How an HTTP Server Actually Works
A /health endpoint that answered in 2ms on my laptop and hung forever in production. No exception. No log line. No crash. The load balancer waited 30 seconds, gave up, marked the instance dead, and rolled a fresh one. That one hung too.
The bug was one missing line. A branch of an if that never called res.end().
You cannot find that bug from inside a framework, because from inside a framework nothing looks wrong. The handler ran. It returned. The logs are clean. You find it by knowing what a server actually is underneath: a process that owns a port, reads bytes off a socket, and writes bytes back. res.end() isn’t housekeeping. It’s the thing that tells the other side the message is over.
So let’s start at the socket and work up.
A server is a process that owns a port
Strip away Express, strip away Node, strip away HTTP itself. A network server does four things, and they have the same names in C, Go, Python, and Node because they’re operating system calls:
- bind claims an address and port from the kernel. “Traffic for 0.0.0.0:3000 is mine now.”
- listen tells the kernel to start queuing up incoming connections on your behalf.
- accept takes one connection off that queue. You now hold a two-way pipe of bytes to exactly one client.
- read / write move bytes through that pipe.
Look at what’s missing from that list. There is no mention of GET, of Content-Type, of status codes, of JSON. The kernel does not know or care that you’re speaking HTTP. It hands you a stream of bytes and a way to send bytes back. Everything else is a convention that both ends agreed to in advance.
Ports, and the error you will see most
A port is a 16-bit number. That’s the whole story: 0 to 65535, a label the kernel uses to decide which process gets an arriving packet. Ports below 1024 are privileged on Unix systems, which is why this happens:
$ node server.js # trying to bind :80 as a normal user
Error: listen EACCES: permission denied 127.0.0.1:80
$ node server.js # and the one you'll hit ten times a week
Error: listen EADDRINUSE: address already in use 127.0.0.1:3000
Don’t fix EACCES by running Node as root. Bind a high port like 3000 and let a reverse proxy or the platform’s load balancer own 80 and 443. A process that parses untrusted bytes from the internet should not be running as root.
EADDRINUSE means two processes tried to claim the same address and port. Usually the culprit is your own last run: you hit Ctrl+C, but the process had spawned a child that survived and still holds the socket. lsof -i :3000 names the offender. Better still, handle the case rather than letting a stack trace be the user experience:
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${PORT} is taken. Is another copy already running?`);
process.exit(1);
}
throw err;
});
The smallest server that actually works
Here is a real HTTP server. Not a toy, not pseudocode. This serves traffic:
import http from 'node:http';
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('hello\n');
});
server.listen(3000, () => {
console.log('listening on http://localhost:3000');
});
Five meaningful lines. Now the interesting question: what did node:http actually do for you there?
It called bind, listen, and accept. It read the raw bytes off each accepted socket. It ran them through a parser that recognises where the request line ends and the headers begin. It built req and res objects. It chose a framing strategy for your response body, added a few headers you never asked for, and decided whether to keep the socket open afterwards.
Every one of those is a decision that can be wrong for your use case. That’s why they’re worth seeing. (For where Node sits among Bun, Deno, and the edge runtimes, see Node and the runtimes.)
What actually arrives on the wire
When a client connects and sends a request, the bytes that land on your socket are text. Genuinely, literally text. Here is a complete HTTP/1.1 request, exactly as it appears:
POST /login HTTP/1.1
Host: api.example.com
Content-Type: application/json
Content-Length: 27
{"user":"ada","pin":"1234"}
That’s it. That’s the protocol. Four structural pieces, in a fixed order:
- The request line. Method, a space, the request target, a space, the version.
POST /login HTTP/1.1. - Field lines, one per line, each a name, a colon, and a value. Everyone calls them headers.
- One empty line. This is the only thing on earth that marks the end of the headers.
- The body, if there is one.
The response is the same shape with a different first line:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 11
{"ok":true}
Those line breaks are not \n. Every line ends with CRLF, a carriage return followed by a line feed, bytes 13 and 10. The header section ends with two CRLFs in a row, which is what an empty line is. If you ever hand-build a request and use bare \n, some servers will accept it out of politeness and others will stare at you until you time out.
Try it yourself, by hand
You don’t need a client library to speak HTTP. You need a socket and a keyboard:
printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' \
| nc example.com 80
A real response comes back. No browser, no fetch, no library. That’s the whole trick of HTTP: it’s a text convention over a byte stream, simple enough to type. The Host header is the one part you can’t skip in HTTP/1.1, because one IP address serves thousands of sites and it’s the only way the server knows which one you meant.
Parse one yourself
Here’s a parser you can poke at. Paste or edit raw request text and watch it get split into a method, a target, a version, a header map, and a body. Note the byte counter at the bottom: change the body without changing Content-Length and see what a server would see.
That mismatch warning is not hypothetical. Getting Content-Length wrong is the foundation of request smuggling: if your server and the proxy in front of it disagree about where one request ends, an attacker can hide a second request inside the body of the first. It’s covered properly in security in depth, but the root cause is right here in this demo. Framing is a security boundary.
What you must send back
Same structure, mirrored. A status line, field lines, an empty line, a body. Node writes the first three for you when you call writeHead, and it adds a few headers on its own initiative.
I pointed a raw TCP socket at a small Node server to capture exactly what it emits. These are the real bytes, with \r\n shown as line breaks:
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 5
Date: Fri, 17 Jul 2026 04:06:11 GMT
Connection: keep-alive
Keep-Alive: timeout=5
hello
I set Content-Type and Content-Length. Node added Date, Connection, and Keep-Alive without being asked. That last pair is the interesting part, and it’s the next section.
Framing: how does anyone know where the body ends?
TCP gives you a stream of bytes with no message boundaries. It will happily deliver your 5-byte body in two packets, or glue your response and the next one together into one packet. The stream has no idea where anything starts or stops. So HTTP has to say so explicitly, and it has exactly two ways to do it.
Content-Length. Count the bytes, put the number in a header, send them. The reader counts that many bytes and stops. Simple, and it requires you to know the total size before you send the first byte.
Transfer-Encoding: chunked. Send the body as a series of pieces, each one prefixed by its own size in hexadecimal. A zero-sized chunk means the message is over. You never have to know the total.
Node picks for you, and the rule is exactly one line long: if you set Content-Length yourself, Node uses it. If you don’t, Node uses chunked. Here is the same server with the header removed, captured off the same raw socket:
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Fri, 17 Jul 2026 04:06:11 GMT
Connection: keep-alive
Transfer-Encoding: chunked
3
one
3
two
0
I called res.write('one'), res.write('two'), res.end(). Node turned each write into a chunk with a hex size in front, and res.end() emitted the 0 chunk that terminates the message. The trailing empty line is real and required.
That’s why chunked exists and why it matters: res.write() sent bytes before the server had any idea how much more was coming. Server-rendered pages that flush the <head> early, log tails, AI token streams, CSV exports of unknown size. All chunked. This is also the layer underneath server-sent events and the machinery behind the Streams API.
Forgetting to end the response
Back to the opening story. What actually happens when a handler never calls res.end()?
I tested it rather than guessing. A server that writes some bytes and then never ends:
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write('partial');
// no res.end() on this path
});
The client is not sitting in silence. It received this, immediately:
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked
7
partial
Headers arrived. A chunk arrived. And then nothing, because the terminating 0 chunk only gets written by res.end(). The client is doing exactly what the protocol tells it to do: waiting for the next chunk size. It will wait as long as you let it.
I left the socket open for nine seconds and the server never touched it. That’s not a bug in my test, it’s the default. Node’s server-side inactivity timeout is off:
| Setting | Default | What it actually limits |
|---|---|---|
server.timeout |
0 (disabled) |
Socket inactivity. Off by default, so nothing kills a stuck handler. |
server.requestTimeout |
300000 (5 min) |
Time to receive the whole request. Not your handler. |
server.headersTimeout |
60000 (60s) |
Time to receive the complete header block. Slowloris defence. |
server.keepAliveTimeout |
5000 (5s) |
Idle time after a response finishes, before closing the socket. |
http.maxHeaderSize |
16384 (16 KiB) |
Total header bytes. Overflow gets a 431. |
Read that table again with the hang in mind. requestTimeout already fired? No, the request arrived fine. keepAliveTimeout will save you? No, that clock only starts after a response completes, and this one never completes. server.timeout is 0.
Nothing in Node’s defaults will rescue a response you forgot to end. The socket sits there holding a file descriptor until the client gives up or your load balancer does. Do enough of them and you exhaust file descriptors and the process stops accepting new connections at all, which looks like a total outage caused by an endpoint nobody uses.
Keep-alive: the reason you reuse a connection
Look again at those captured bytes. Connection: keep-alive and Keep-Alive: timeout=5, added by Node, unrequested.
HTTP/1.1 is persistent by default. Unless somebody says Connection: close, the connection stays open after a response and the next request can go down the same socket. That’s not a micro-optimisation. Opening a TCP connection costs a three-way handshake, so one full round trip before a single useful byte moves. Add TLS and it’s another round trip on top. On a 60ms link that’s 120ms of pure ceremony, per request, buying nothing.
The Keep-Alive: timeout=5 header is Node telling the client how long it intends to hold the socket open while idle. That number comes straight from server.keepAliveTimeout, which defaults to 5000 milliseconds.
The 502 that only happens under load
Here’s where that 5 becomes a production incident, and it’s the single most useful thing here.
A connection can be closed at the same moment a request is sent down it. The client picked a socket from its pool and wrote a request. The server had already decided that socket was idle for too long and sent a FIN. Neither side did anything wrong. The request lands on a closing socket and dies.
Your load balancer reports it as a 502. Sporadic, unreproducible, worse under load, absent in staging. Everyone blames the network.
The fix is an ordering rule: the thing that closes idle connections must be downstream of the thing that reuses them. Whatever sits in front of your Node process has its own idle timeout. If Node’s 5 seconds is shorter than the proxy’s, Node will keep closing sockets the proxy still thinks are good.
const server = http.createServer(app);
// Must exceed the idle timeout of whatever proxies to this process.
server.keepAliveTimeout = 65_000; // Node's default is 5_000
server.headersTimeout = 66_000; // keep this above keepAliveTimeout
The classic case is AWS ALB, whose idle timeout defaults to 60 seconds, hence the 65 above. Check the actual number for your proxy instead of copying mine. headersTimeout has to stay above keepAliveTimeout or you re-create the race in a new place.
HTTP/2 and HTTP/3: the framing changes, the meaning doesn’t
Everything above described HTTP/1.1, where a connection carries one request at a time. Want six requests in parallel? Six connections. Browsers cap that at about six per origin, which is why the old advice was to shard assets across subdomains.
HTTP/2 kept every idea above and changed how the bytes are packed. Instead of CRLF-delimited text you get binary frames, each tagged with a stream id, so many requests and responses interleave over one TCP connection. Headers get compressed with HPACK, because sending the same 800 bytes of cookies on every request was absurd. A GET is still a GET. 404 still means 404.
HTTP/3 kept the semantics again and swapped the transport. It runs over QUIC on UDP instead of TCP, standardised in 2022. The reason is subtle and worth knowing: HTTP/2 fixed head-of-line blocking at the application layer, but TCP still delivers one ordered stream, so a single lost packet stalls every multiplexed request on that connection until it’s retransmitted. QUIC understands streams natively, so a lost packet only stalls the stream it belonged to. TLS 1.3 is built into the handshake rather than layered on, which cuts a round trip off connection setup.
Two practical notes, because this area moves and the marketing runs ahead of the reality.
First, adoption. As of mid-2026 HTTP/2 still carries the largest share of traffic, HTTP/3 sits somewhere between roughly 20% and 40% depending on whose methodology you trust, and HTTP/1.1 is stubbornly alive. Those numbers disagree because some count sites and some count page loads, so treat any single figure with suspicion. HTTP/3 growth has flattened, partly because UDP is still blocked or throttled on plenty of corporate networks and clients fall back to TCP.
Second, and more usefully: you probably terminate none of this yourself. In a normal deployment your CDN or load balancer speaks HTTP/2 or HTTP/3 to the browser and plain HTTP/1.1 to your Node process over a local network where handshakes are cheap. Node does ship an node:http2 module and it works, but reaching for it directly is rare. The version matters enormously for the browser hop and usually not at all for yours. Deploying apps covers where that boundary actually sits.
So what do Express and Fastify actually add?
Given all of the above, it’s fair to ask what a framework is for. Nothing you couldn’t write yourself. It just does the twenty small things you’d get slightly wrong.
Take the raw server and try to grow it. You need to match req.method and req.url against patterns, which means a router. You need to read the body, which means accumulating chunks and enforcing a size limit so one client can’t buffer 4GB into your heap. You need content-type parsing. You need every thrown error to become a 500 instead of a hung socket. You need a way to run shared logic before handlers.
That list is Express: a router, a body parser, an error boundary, and a middleware chain sitting on http.createServer. Fastify is the same shape with a faster router, real schema validation, and a serialiser that beats JSON.stringify by precompiling for a known shape.
The tell is right here:
import express from 'express';
import http from 'node:http';
const app = express();
app.get('/', (req, res) => res.send('hello'));
// This works, because an Express app IS a request listener.
const server = http.createServer(app);
server.listen(3000);
app is a function with the signature (req, res). That’s all it ever was. When app.listen(3000) is called, Express calls http.createServer(this).listen(...) internally. The same req and res from the bare example flow through, with properties bolted on.
Which is why knowing this layer pays off. When your Express app returns a 431, you know a client sent more than 16 KiB of headers and it’s probably a cookie that grew. When you set res.setHeader after the response was sent and get ERR_HTTP_HEADERS_SENT, you know the status line and headers are already bytes on a wire and cannot be recalled. When a streaming endpoint buffers instead of streaming, you know somebody set Content-Length and forced Node out of chunked mode.
Summary
- A server binds a port, listens, accepts connections, and moves bytes. The kernel knows nothing about HTTP; the protocol is a convention both ends agreed to.
EADDRINUSEmeans another process holds the port, usually your last run.EACCESon ports below 1024 means bind a high port and let a proxy own 80 and 443 instead of running as root.- An HTTP message is four parts in order: a first line, field lines, one empty line, then an optional body. Lines end with CRLF, and the empty line is the only marker for where headers stop.
- A request line carries the method, the path with its query, and the version. The host lives in the
Hostheader, which is whyreq.urlis not a full URL. - Bodies need explicit framing because TCP has no message boundaries.
Content-Lengthneeds the total up front;Transfer-Encoding: chunkedprefixes each piece with a hex size and ends with a0chunk. Node picks chunked whenever you haven’t set a length. - If both framing headers appear,
Transfer-Encodingwins, and the disagreement between hops is exactly how request smuggling works. res.end()writes the terminating chunk. Skip it and the client waits forever, becauseserver.timeoutdefaults to0and no other Node timeout covers a stalled handler.- HTTP/1.1 is persistent by default. Keep-alive removes a handshake per request, and Node’s
keepAliveTimeoutof 5s must be raised above your proxy’s idle timeout or you’ll get intermittent 502s under load. - HTTP/2 and HTTP/3 change the framing (binary frames, multiplexed streams, QUIC over UDP) and change none of the semantics. Usually your CDN terminates them and talks HTTP/1.1 to you anyway.
- Express and Fastify are a router, a body parser, an error boundary, and a middleware chain over
http.createServer. An Expressappis literally a(req, res)listener.