req and res aren't special HTTP-only objects — req is a Readable stream and res is a Writable stream, the exact same primitives from Streams & Buffers wearing HTTP clothing. Understanding that turns 'how does Express work' from a black box into a direct application of mechanics you already know.
httpcreateServerrequest-responsekeep-alivestreams
Knowledge Check
20 questions · pass at 70%
0/20
easymcq
1. What are req and res, in terms of their underlying type?
Interview Questions
2 questions
Cheatsheet
Download-ready reference
Cheatsheet
http.createServer((req, res) => { ... }).listen(port);
req (IncomingMessage) = a REAL Readable stream
res (ServerResponse) = a REAL Writable stream
→ SAME backpressure/data/end/drain mechanics as Streams & Buffers
⚠️ NO native req.body — body arrives via:
req.on('data', chunk => ...) // Buffer chunks
req.on('end', () => ...) // body fully received
(or: req.pipe(destination) for automatic backpressure handling)
res.writeHead(status, headers) → set status + headers (once, before body)
res.write(chunk) → send a chunk (streams to client immediately)
res.end([chunk]) → finish the response
⚠️ forgetting res.end() → request hangs, client never completes
KEEP-ALIVE (HTTP/1.1 default): TCP socket REUSED across requests from
the same client — avoids repeated handshake overhead.
Hand-rolled routing = method + url lookup table.
Frameworks (Express, etc.) ADD: path params, body parsing,
middleware chaining — built ON TOP of these same primitives,
not a replacement for them.
Large uploads: PIPE to destination, don't buffer the whole body in
memory — set a size limit to prevent memory exhaustion.
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.
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.
A request's full lifecycle: socket → parse → handler → response → reuse
step 1 / 5
const server = http.createServer((req, res) => {
// ...
});
server.listen(3000);
Socket
waiting
TCP connection open
→
Parse
idle
no data yet
→
Handler
idle
not invoked
→
Response
idle
nothing sent
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.
const server = http.createServer((req, res) => { let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => { const parsed = JSON.
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.
req is a Readable stream — a large upload still respects backpressure
step 1 / 3
http.createServer((req, res) => {
let received = 0;
req.on('data', (chunk) => {
received += chunk.length;
});
});
Client upload
active
sending large file
→
req (Readable)
active
chunk by chunk
→
Handler
waiting
accumulating bytes
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.
The same write()-returns-false / 'drain' mechanism applies to res, the writable side
step 1 / 5
const ok = writable.write(chunk1);
// ok === true — buffer has room, keep writing
Producer
writing
pushing data
Internal bufferhighWaterMark: 16kb
chunk1
Consumer
idle
not reading yet
A fast producer writes chunk1. The internal buffer has plenty of room below the highWaterMark, so write() returns true — the producer keeps going immediately.
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.
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.
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.
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.
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.
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 memory
For 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.
Treat req/res as 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 eventual res.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.
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 calling res.end() once) lets the client start receiving bytes sooner — directly applying the same time-to-first-byte benefit that motivates streaming generally.