The Request & Response Objects
Every request handler in Node’s http module receives two objects: a request and a response. The request is an IncomingMessage — a readable stream carrying the method, URL, headers, and body sent by the client. The response is a ServerResponse — a writable stream you use to send back a status line, headers, and a body. Understanding the shape and lifecycle of these two objects is the foundation for building anything from a tiny API to a full web framework.
The request object (IncomingMessage)
When the server’s 'request' event fires, the first argument is an http.IncomingMessage. It is a Readable stream, which means you can pipe it, listen for 'data'/'end', or consume it with async iteration. Beyond the body, it exposes metadata parsed from the incoming HTTP message.
The most commonly used properties are:
| Property | Type | Description |
|---|---|---|
req.method | string | HTTP verb, e.g. "GET", "POST" (always uppercase). |
req.url | string | Request target path and query, e.g. "/users?page=2". |
req.headers | object | Lowercased header names mapped to values. |
req.httpVersion | string | Protocol version, e.g. "1.1". |
req.socket | net.Socket | Underlying connection (remote address, TLS info). |
Note that req.url is only a path, never a full URL. To parse the query string and path safely, construct a URL using the host header as a base:
import { createServer } from 'node:http';
const server = createServer((req, res) => {
const { pathname, searchParams } = new URL(req.url, `http://${req.headers.host}`);
console.log(`${req.method} ${pathname}`);
console.log('User-Agent:', req.headers['user-agent']);
res.end(`Path: ${pathname}, page: ${searchParams.get('page') ?? '1'}`);
});
server.listen(3000, () => console.log('Listening on http://localhost:3000'));
Output: (after curl "http://localhost:3000/users?page=2")
GET /users
User-Agent: curl/8.7.1
Header names in
req.headersare always lowercased by Node, so always read them asreq.headers['content-type'], never'Content-Type'. For headers that can legally appear multiple times (likeset-cookie), usereq.headersDistinctto get arrays.
Reading a streamed request body
The body of a request is not buffered for you — it arrives as a stream of chunks. For small payloads you can collect those chunks, but the streaming model lets you process gigabytes without holding them all in memory. The cleanest modern approach is for await...of, since IncomingMessage is an async iterable.
import { createServer } from 'node:http';
const server = createServer(async (req, res) => {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'text/plain' });
return res.end('Method Not Allowed');
}
const chunks = [];
let total = 0;
try {
for await (const chunk of req) {
total += chunk.length;
if (total > 1_000_000) { // guard against oversized uploads
res.writeHead(413).end('Payload Too Large');
req.destroy();
return;
}
chunks.push(chunk);
}
} catch (err) {
res.writeHead(400).end('Read error');
return;
}
const body = Buffer.concat(chunks).toString('utf8');
console.log(`Received ${total} bytes`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: total }));
});
server.listen(3000);
Output: (after curl -X POST --data '{"name":"Ada"}' http://localhost:3000)
Received 14 bytes
Because the request is a real stream, you can also pipe it directly — for example, streaming an upload to disk with req.pipe(fs.createWriteStream('upload.bin')) without ever buffering it.
The response object (ServerResponse)
The second handler argument is an http.ServerResponse, a Writable stream. You control the status code and headers first, then write the body, and finally signal completion with end(). Once a single byte of the body (or writeHead) has been sent, the headers are flushed and can no longer be changed.
| Method | Purpose |
|---|---|
res.statusCode / res.statusMessage | Set the response status before sending. |
res.setHeader(name, value) | Set or replace a header (before sending). |
res.getHeader(name) | Read a header you’ve staged. |
res.writeHead(status, [headers]) | Send the status line and headers in one call. |
res.write(chunk) | Write a body chunk; flushes headers if not yet sent. |
res.end([chunk]) | Send the final chunk (optional) and finish the response. |
There are two equivalent ways to set headers. setHeader stages them individually and is convenient when building a response across several steps; writeHead sends the status and a header object together in one shot. You can mix them — anything staged with setHeader is merged when writeHead runs.
import { createServer } from 'node:http';
const server = createServer((req, res) => {
// Staged incrementally:
res.setHeader('X-Powered-By', 'DevCraftly');
res.setHeader('Cache-Control', 'no-store');
// Status line + content type sent together; staged headers are merged in:
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.write('<h1>Hello</h1>');
res.write('<p>Streamed in two writes.</p>');
res.end();
});
server.listen(3000);
Output: (response headers from curl -i http://localhost:3000)
HTTP/1.1 200 OK
X-Powered-By: DevCraftly
Cache-Control: no-store
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Notice the Transfer-Encoding: chunked header: because we called write multiple times without setting a Content-Length, Node automatically uses chunked transfer encoding. If you know the full body up front, set Content-Length (or just pass the whole body to res.end(body)) so the client gets the length immediately.
Always call
res.end()exactly once per request. Forgetting it leaves the client hanging until a timeout; calling it twice (or writing after it) throwsERR_STREAM_WRITE_AFTER_END. Returning early from a branch is the usual cause —return res.end(...)is a safe habit.
Best practices
- Build a
URLfromreq.urlplus thehostheader instead of hand-parsing strings, so query parsing and decoding are handled correctly. - Treat
req.headerskeys as lowercase, and reach forreq.headersDistinctwhen a header may legally repeat. - Never trust the request body’s size — enforce a byte cap while streaming to avoid memory-exhaustion attacks.
- Set the status code and all headers before the first
res.write; once the body flows, headers are locked. - Prefer
res.end(body)for known payloads so Node can send an accurateContent-Length; reserve chunked writes for genuinely streamed output. - Always finish with a single
res.end(), even on error paths, and pair it with an appropriate status code.