open atlas
↑ Back to track
Node.js, zero to senior NODE · 05 · 01

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.

NODE Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

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:

KnobWhat it boundsDefault
server.requestTimeoutTime to receive the entire request (headers + body)300000 ms (5 min)
server.headersTimeoutTime to receive the complete request headersmin(requestTimeout, 60000)
server.keepAliveTimeoutIdle time a kept-alive socket waits for the next request5000 ms
server.timeout (per-socket)Inactivity on a socket before it is destroyed0 (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);
}
Quiz

In an http.createServer handler, you log req.body inside the callback and get undefined for a POST request. Why?

Quiz

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?

Pick the best fit

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

Order the steps to handle a streamed upload safely in a raw http handler, from request arrival to clean completion:

  1. 1 Inspect req.method and req.headers; reject anything but the expected method/content-type early
  2. 2 Attach req.on('error', …) and handle 'aborted'/'close' so a mid-upload disconnect can't crash the process
  3. 3 Stream the body with for await…of req, tracking a running byte total against a hard max
  4. 4 On exceeding the cap, respond 413 and req.destroy() to stop reading and release the socket
  5. 5 On success, process the assembled body, then res.writeHead + res.end the response
Recall before you leave
  1. 01
    What does res.write() returning false mean, and what must you do about it?
  2. 02
    Why is a hand-rolled http.createServer a Slowloris and OOM risk that a framework usually isn't, and which specific defaults close the gap?
Recap

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.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.