Concept
The beginner framing: before reaching for Express or any framework, it's worth seeing what Node actually hands you natively, a working HTTP server in about five lines, with no dependencies.
const http = require("http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello, world!");
});
server.listen(3000);The precise mental model: http.createServer() returns a Server instance that listens for TCP connections and, for each incoming HTTP request, invokes your callback with two objects, req (an IncomingMessage) and res (a ServerResponse). The single most important fact about both, confirmed by checking their actual prototype chains in this app's installed Node v23.11.0: req is a Readable stream, and res is a Writable stream, the exact same stream primitives from Streams & Buffers, not a separate HTTP-specific abstraction.
console.log(req instanceof require("stream").Readable); // true
console.log(res instanceof require("stream").Writable); // trueThe request lifecycle
const server = http.createServer((req, res) => {// ...});server.listen(3000);
A client opens a TCP connection to the server. Node's HTTP parser is attached to the socket, waiting for bytes to arrive, nothing else happens until data shows up.
A client's TCP connection arrives; Node's built-in HTTP parser incrementally builds req as bytes come in, starting with the request line and headers; your handler runs once headers are parsed (the body, if any, may still be arriving); res.write()/res.end() send the response back over the same socket. With HTTP/1.1 keep-alive (the default), that socket is then reused for the client's next request rather than torn down, avoiding a fresh TCP handshake per request, a meaningful latency win under repeated requests from the same client.
req as a stream: bodies arrive in chunks
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => {
const parsed = JSON.parse(body);
res.end(`Received: ${parsed.name}`);
});
});There is no req.body built into Node, that convenience is exactly what body-parsing middleware (Express's express.json(), for instance) provides. Natively, a request body arrives as a sequence of 'data' events carrying Buffer chunks, terminated by an 'end' event, precisely the Readable-stream contract from the streams topic, now applied to HTTP.
http.createServer((req, res) => {let received = 0;req.on('data', (chunk) => {received += chunk.length;});});
A client uploads a large file. `req` is a Readable stream, the body does NOT arrive all at once; it arrives as a sequence of 'data' events, each carrying one chunk, exactly like any other readable stream.
For a large upload, the same backpressure mechanics apply: if the handler pipes the body somewhere slow (disk, a remote API), req.pipe(destination) handles pause/resume automatically, exactly as it does for any other stream pair.
const ok = writable.write(chunk1);// ok === true, buffer has room, keep writing
A fast producer writes chunk1. The internal buffer has plenty of room below the highWaterMark, so write() returns true, the producer keeps going immediately.
Hand-rolled routing: why frameworks exist
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/users") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: 1, name: "Ada" }]));
} else if (req.method === "POST" && req.url === "/users") {
// ...body parsing, then respond
} else {
res.writeHead(404).end(
This works, but it makes obvious exactly what a framework buys you: route pattern matching (/users/:id), automatic body parsing, middleware chaining, consistent error handling, all of it is built on top of these same createServer/req/res primitives. Express, in particular, doesn't replace this model; it wraps it.
Try It
Predict the output before checking the solution.
const server = http.createServer((req, res) => {
res.write("first chunk ");
setTimeout(() => {
res.write("second chunk ");
res.end("done");
}, 100);
});
server.listen(3000);
// client does: fetch('http://localhost:3000').then(r => r.text()).then(console.log)Does the client receive the response as one piece, or does something else happen?
Solution
The client's fetch still resolves with the full concatenated body, "first chunk second chunk done", because HTTP response streaming is transparent to the caller unless the client is specifically reading the response as a stream itself. But on the wire, the three res.write()/res.end() calls are sent as separate chunks (using chunked transfer-encoding, since no Content-Length was set upfront), the server did NOT wait for all the data to be ready before starting to send. This is the practical benefit of res being a real stream: you can start sending a response before the entire payload exists.
Implement It Yourself
Build a minimal router that dispatches by method + path, without any framework:
function createRouter() {
const routes = [];
return {
add(method, path, handler) {
routes.push({ method, path, handler });
},
handle(req, res) {
const match = routes.find((r) => r.method === req.method && r.path === req.url);
if (!match) {
res.writeHead(404).end("Not found");
return;
}
This is the essential shape of what every Node HTTP framework does at its core, a lookup table matching method+path to a handler function, dispatched from inside the one createServer callback.
Under the Hood
req/res being real Readable/Writable streams is a direct, literal application of Streams & Buffers, the backpressure mechanics, the 'data'/'end'/'drain' events, and .pipe() all apply unchanged. And every request handler invocation is itself scheduled and executed as part of the event loop's phases (see Event Loop & libuv), specifically surfacing through the poll phase once the underlying socket I/O is ready. How to handle a handler that throws or rejects mid-request is covered in depth in Error Handling & Async Context.
Common Mistakes
1. Assuming req.body exists natively
const server = http.createServer((req, res) => {
console.log(req.body); // ❌ undefined, Node has no built-in body parsing
});Node parses headers and exposes the body as a raw stream, JSON/form parsing is something you (or a framework) must do explicitly by consuming req's 'data'/'end' events.
2. Forgetting to call res.end()
const server = http.createServer((req, res) => {
res.write("partial response");
// ❌ no res.end(), the connection just hangs, client never gets a complete response
});Without res.end(), the response is never considered complete, the client's request appears to hang indefinitely.
3. Buffering an entire large request body in memory before processing it
let body = "";
req.on("data", (chunk) => { body += chunk; }); // ❌ no size limit, a huge upload exhausts memoryFor uploads of unbounded or very large size, accumulating the entire body in a string/array before processing defeats the purpose of streaming, pipe directly to the eventual destination (disk, a parser that itself streams) instead, and enforce a maximum size.
Best Practices
- Treat
req/resas the streams they are, use.pipe()for large bodies/responses instead of manually buffering everything in memory first. - Always pair
res.write()calls with an eventualres.end(), even in early-return error paths. - Reach for a framework once routing/middleware complexity grows, hand-rolled routing is valuable for understanding the mechanics, but a real application benefits from the tested, standard abstractions (Express, etc.) built on these same primitives.
- Set a body-size limit when manually accumulating a request body, to prevent a malicious or mistaken huge upload from exhausting server memory.
Performance Tips
- HTTP/1.1 keep-alive (default) reuses the underlying TCP connection across multiple requests from the same client, avoiding a fresh TCP handshake (and TLS handshake, if HTTPS) per request is a real, measurable latency improvement under repeated traffic.
- Streaming a response with
res.write()calls as data becomes available (rather than buffering the entire response in memory first, then callingres.end()once) lets the client start receiving bytes sooner, directly applying the same time-to-first-byte benefit that motivates streaming generally.
