The http module: servers, clients, and streams
In raw http, req is an IncomingMessage (a Readable body + headers) and res is a ServerResponse (a Writable). Stream the body, respect backpressure, reuse sockets with keep-alive, and add the timeouts and body cap that http omits — or a slow client exhausts your sockets.
An internal API went dark on a Tuesday afternoon — no errors, no crash, just every request hanging. The Node process was healthy, CPU near zero, heap flat. The cause was a single misbehaving cron client on a flaky link: it opened connections, sent a few header bytes, and then sent the rest one byte every few seconds. The hand-rolled http.createServer had no requestTimeout and no headersTimeout, so each of those half-open sockets sat in the connection pool forever. Within minutes the slow client had quietly occupied every available socket, and legitimate traffic could no longer get a connection. Nothing in the code was “wrong” — it was just missing the timeouts that every framework sets for you.
By the end of this lesson you will know exactly which knobs Node’s http module omits and why a raw server without them is a silent liability.
req is a Readable, res is a Writable
http.createServer((req, res) => …) hands you two stream objects, and the single most common beginner mistake is forgetting that. req is an IncomingMessage — a Readable stream of the request body, decorated with req.method, req.url, and req.headers. The headers are parsed and present immediately, but the body is not buffered for you: it arrives as stream chunks, and if you never read them, you never get the body (and the socket can stall). res is a ServerResponse — a Writable stream. You start it with res.writeHead(status, headers), push bytes with res.write(chunk), and finish with res.end().
Because the body is a stream, you consume it with stream idioms, not by reaching for a magic .body property:
import { createServer } from "node:http";
const server = createServer(async (req, res) => {
if (req.method === "POST") {
let body = "";
// req is async-iterable: each chunk is a Buffer
for await (const chunk of req) {
body += chunk; // (see the size-cap warning below)
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ received: body.length }));
return;
}
res.writeHead(405).end();
});
server.listen(3000);The equivalent event API — req.on("data", chunk => …) then req.on("end", …) — does the same thing; for await is just the modern surface over it. Either way, also handle req.on("error", …) and the 'aborted'/'close' case: a client that disconnects mid-upload fires an error on the request stream, and an unhandled stream 'error' event crashes the process.
Backpressure: write() can say “stop”
When you send a large or unknown-length response, you cannot just blast res.write() in a loop. res.write() returns a boolean: true means the chunk was flushed to the kernel socket buffer; false means it was queued in your process’s memory because the buffer is full. Ignoring that false and continuing to write is how a single slow client makes your Node heap balloon — you keep buffering data the network can’t drain yet. The contract is: when write() returns false, stop and wait for the 'drain' event before writing more.
// Manual backpressure — only continue when the buffer has room
function writeChunk(res, chunk, next) {
if (res.write(chunk)) {
next(); // buffer had room, keep going
} else {
res.once("drain", next); // wait until it empties
}
}In practice you almost never write that loop by hand. If your source is itself a stream — a file, an upstream response, a transform — pipe it and let Node manage backpressure end to end:
import { createReadStream } from "node:fs";
// pipeline propagates backpressure AND forwards errors to one place
import { pipeline } from "node:stream/promises";
await pipeline(createReadStream("./big.csv"), res);When the body length is unknown (a stream with no content-length), Node switches the response to chunked transfer encoding automatically, sending each write() as its own framed chunk terminated by a zero-length chunk at end().
▸Why this works
Why not just buffer the whole body or response in memory and skip streaming? Because memory is the resource an attacker (or a bad client) attacks first. A 4 GB upload buffered into a string is 4 GB of resident heap per request; a hundred concurrent ones is an OOM kill. Streaming keeps only a chunk in flight at a time, so memory stays bounded regardless of payload size — which is exactly why the size cap below matters.
The client, the Agent, and keep-alive
When you call a downstream service thousands of times per second, connection setup cost adds up fast — and that’s exactly what the Agent is designed to eliminate. The same module is also an HTTP client: http.request(options, cb) returns a writable request stream, and http.get is the shorthand that calls req.end() for you. The response arrives as another IncomingMessage you must drain.
import { request } from "node:http";
const req = request("http://api.internal/users/1", (res) => {
let data = "";
res.on("data", (c) => (data += c)); // you MUST consume the response
res.on("end", () => console.log(res.statusCode, data));
});
req.on("error", console.error); // network errors land here
req.end();Behind every client request is an Agent that pools sockets. The point of pooling is keep-alive: reusing a live TCP connection across requests skips a fresh TCP handshake (~1 RTT) and, on HTTPS, a full TLS handshake on top — a large per-request saving when you call the same host repeatedly. The global agent enables keepAlive by default since Node 19; before that you opted in with new Agent({ keepAlive: true }). The knob that bites under load is maxSockets — the cap on concurrent connections the agent opens per host. It defaults to Infinity, so an unbounded fan-out can open thousands of sockets at once and exhaust ports or overwhelm the upstream; set a sane maxSockets when you call a single dependency hard.
Timeouts and body limits: what http does NOT do for you
This is the senior part, and it is all about what http omits. Raw http will, by default, let a client take its time and send a body of any size. Both are denial-of-service vectors. The timeout knobs that bound a slow client are server properties:
| Knob | What it bounds | Default |
|---|---|---|
server.requestTimeout | Time to receive the entire request (headers + body) | 300000 ms (5 min) |
server.headersTimeout | Time to receive the complete request headers | min(requestTimeout, 60000) |
server.keepAliveTimeout | Idle time a kept-alive socket waits for the next request | 5000 ms |
server.timeout (per-socket) | Inactivity on a socket before it is destroyed | 0 (disabled) |
The hook is exactly the gap requestTimeout and headersTimeout close: a Slowloris attack drips headers or body slowly to hold sockets open. With the default 300 s requestTimeout and the 60 s headersTimeout, a half-open socket is reclaimed instead of leaking forever — but if you read req.socket.setTimeout(0) or disable these on a hand-rolled server, you reopen the door.
The other omission is body size. There is no built-in limit: for await (const chunk of req) body += chunk will happily accumulate a multi-gigabyte upload into your heap and OOM-kill the process. You must cap it yourself (this is one of the main things express.json({ limit }) or body-parser do for you):
const MAX = 1_000_000; // 1 MB
let size = 0, chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > MAX) {
res.writeHead(413).end("payload too large"); // 413 Payload Too Large
req.destroy(); // stop reading, free the socket
return;
}
chunks.push(chunk);
}In an http.createServer handler, you log req.body inside the callback and get undefined for a POST request. Why?
Your service makes thousands of outbound calls to one upstream and you enable keep-alive on the agent. What does keep-alive primarily save per request?
You are accepting file uploads on a public endpoint built on raw http.createServer. How do you keep a malicious client from OOM-killing the process with one giant upload?
Order the steps to handle a streamed upload safely in a raw http handler, from request arrival to clean completion:
- 1 Inspect req.method and req.headers; reject anything but the expected method/content-type early
- 2 Attach req.on('error', …) and handle 'aborted'/'close' so a mid-upload disconnect can't crash the process
- 3 Stream the body with for await…of req, tracking a running byte total against a hard max
- 4 On exceeding the cap, respond 413 and req.destroy() to stop reading and release the socket
- 5 On success, process the assembled body, then res.writeHead + res.end the response
- 01What does res.write() returning false mean, and what must you do about it?
- 02Why is a hand-rolled http.createServer a Slowloris and OOM risk that a framework usually isn't, and which specific defaults close the gap?
The http module hands your handler two streams: req, an IncomingMessage that is a Readable carrying the body plus method/url/headers, and res, a ServerResponse that is a Writable you drive with writeHead/write/end. There is no auto-parsed req.body — you consume the body with for await…of req (or data/end events) and you must handle the stream’s 'error'/'aborted' cases or an aborted upload crashes the process. Writing a response means respecting backpressure: res.write() returns false when the kernel buffer is full, so wait for 'drain', or just pipeline() a source stream into res and let Node handle it (it switches to chunked encoding when length is unknown). The same module is a client via http.request/http.get, pooling sockets through an Agent whose keep-alive (on by default since Node 19) reuses a live connection to skip a TCP handshake RTT and a TLS handshake per request, with maxSockets (default Infinity) capping per-host concurrency. The senior discipline is everything http doesn’t do: set server.requestTimeout (default 300000 ms) and server.headersTimeout (min(requestTimeout, 60000)) so a Slowloris can’t pin your sockets, mind keepAliveTimeout (5000 ms), and cap the body size yourself — raw http will read an unbounded payload straight into the heap, so track bytes and reply 413 past a limit, exactly as frameworks do for you. Now when you reach for http.createServer directly, you’ll immediately ask: where’s the requestTimeout, and where’s the body cap?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.