Request and Response Objects
What req and res really are: a mutable view over a socket, with a body that has not been read and a response that has a point of no return.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What is the req object my handler receives, and why does the response sometimes refuse to be changed?
Handlers need somewhere to read the caller's input and somewhere to put the answer, and middleware needs somewhere to attach what it discovered about the caller.
req is a plain object holding everything about the request, res is a plain object I fill in and return. Both are just data.
"Cannot set headers after they are sent" in production, from a code path where two branches both responded — usually an error handler firing after a successful response has already started (The Error Boundary).
- "Cannot set headers after they are sent" in production, from a code path where two branches both responded — usually an error handler firing after a successful response has already started (The Error Boundary).
req.bodyis undefined, because nothing has consumed the body stream yet: the body-parsing middleware is missing, or it ran only for some content types.- A value attached to
reqby middleware — the authenticated user, a tenant id — is present on some routes and undefined on others, because middleware order differs per route (Middleware Ordering Is a Correctness Decision). - A handler reads the body twice and the second read returns nothing, because a stream is consumed, not stored.
- A background task started inside a handler keeps a reference to
reqandresafter the response has been sent, and writes to a socket that is closed.
What is actually happening
reqis a view over a connection, not a snapshot. Method, URL and headers are parsed and available; the body is usually still an unread stream (Parsing HTTP).resis a writer with phases: status and headers can be set until the first byte of the response is written, and not afterwards. The transition is one-way, and most frameworks call it "headers sent".- Both objects are mutable and request-scoped. Middleware attaching
req.useris using the request object as the request-scoped context, which is why its lifetime and its ordering matter (Request Context Propagation). - Frameworks add convenience on top:
req.bodyafter a body parser has run,req.paramsafter routing has matched,req.queryafter the query string is parsed. Every one of those is a *previous step's output*, not an intrinsic property. - The response body may be produced all at once or streamed. Setting
Content-Lengthcommits you to a size; omitting it means chunked framing and the ability to write as you go (Request Bodies and Streaming). - Once the handler returns, the response is not necessarily delivered — writes to a socket are I/O and the client controls the read rate.
The response has a point of no return
The whole model fits in one picture. Before the first byte, everything is editable: status, headers, whether you respond at all. After the first byte, the status line is on the wire and the only remaining choices are what body to write and when to end.
This is why error handling is harder than it looks. An error handler that assumes it can send a 500 is assuming nothing has been written — an assumption that is false exactly in the cases where streaming was worth doing.
The body is a stream, and it is consumed once
The most common surprise in this lesson is that the request object arrives with the body unread. That is not an oversight — it is what makes it possible to reject a request on its headers alone, before spending memory on its content.
It also means the raw bytes are gone once something has parsed them. Where a signature must be verified over the exact bytes, that ordering is a correctness requirement rather than a preference.
1import { createHmac, timingSafeEqual } from 'node:crypto'2 3// The body arrives once. If JSON.parse runs first, the exact bytes4// are gone -- and a re-serialised object is NOT byte-identical5// (key order, whitespace, number formatting all differ).6async function readRawBody(req: NodeJS.ReadableStream, limit: number) {7 const chunks: Buffer[] = []8 let size = 09 for await (const chunk of req) {10 size += (chunk as Buffer).length11 if (size > limit) throw new PayloadTooLarge()12 chunks.push(chunk as Buffer)13 }14 return Buffer.concat(chunks)15}16 17async function handleWebhook(req: any, res: any) {18 const raw = await readRawBody(req, 256 * 1024)19 20 // 1. verify over the RAW bytes21 const expected = createHmac('sha256', secret).update(raw).digest()22 const provided = Buffer.from(req.headers['x-signature'] ?? '', 'hex')23 if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {24 res.writeHead(401).end()25 return // <- terminal. nothing below runs.26 }27 28 // 2. only now parse29 const event = JSON.parse(raw.toString('utf8'))30 await process(event)31 32 res.writeHead(204).end()33}Two rules in one function: the size limit is applied while reading rather than after, and the return after responding is what stops this from becoming a double-response bug the first time someone adds code below it.
What is really on the request object
Categorising the fields by origin is the habit worth building. Two fields sitting next to each other can have completely different trust levels, and the object gives you no visual clue.
| Field | Where it came from | Trust |
|---|---|---|
method, url | The request line, parsed | Attacker-controlled, well-formed |
headers | The header block, normalised | Attacker-controlled unless a trusted proxy overwrites it |
query | Parsed from the URL after routing setup | Attacker-controlled; type is always string or array |
params | Produced by the router from the matched path | Attacker-controlled; usually the object-level authz input (Object-Level Authorization) |
body | Output of a body-parsing step, not intrinsic | Attacker-controlled; absent if nothing parsed it |
cookies | A header, parsed | Attacker-controlled unless signed or encrypted |
req.user / req.auth | Attached by authentication middleware | Trusted, and only as far as the verification was correct |
req.correlationId | Attached at the edge or generated | Trusted for correlation, never for authorization |
How to build it
Most important first.
- Treat responding as terminal. One request, one response: return immediately after responding, and make error handling aware that a response may already be underway.
- Read the body exactly once, at a defined point, with a size limit, and store the parsed result. Do not pass the raw stream deeper into the application (Request Bodies and Streaming).
- Keep raw bytes when a signature must be verified over them: JSON parsing and re-serialising changes the bytes, which invalidates HMAC verification (Webhook Signature Verification).
- Attach derived facts to a request-scoped context, and give them names that say where they came from —
req.auth.userIdfrom a verified token, never auserIdthat could have arrived in the body (The Trust Boundary). - Never let a reference to
reqorresescape the request. Work that outlives the response belongs in a job with its own inputs (Background Jobs). - Set
Content-Typeand, when you know it,Content-Lengthexplicitly. Content sniffing by clients is a source of both bugs and vulnerabilities.
What can go wrong
- Double response: a validation failure returns a 400 without stopping, then the handler continues and tries to send a 200. The second write throws, often inside an error handler where it is hardest to see.
- A response streamed to a client that disconnects mid-write; the framework surfaces this as an error on the response object rather than as a request failure.
- Middleware mutating
reqin a way a later stage did not expect, such as normalising a field the signature check needed unmodified. - An error thrown *after* headers were sent, so the error handler cannot change the status code — the client receives a 200 with a truncated body.
- The mitigation failing: a global "have we responded?" flag that is checked but not set on every path, giving false confidence.
- A client disconnect can arrive while your handler is writing, so the response object transitions to closed underneath in-progress work.
- Two asynchronous paths in one handler — a timeout timer and the real work — can both try to respond. Whichever fires first wins, and the loser throws (Timeouts).
- Middleware that attaches to a request object shared with a retried or cloned request will surprise you; request-scoped means scoped to *this* execution.
- Everything on
reqthat came from the wire is attacker-controlled: body, query, params, headers, cookies. Everything derived from a verified credential is not. Naming should make the difference impossible to miss. - Never echo unvalidated input into a response header. CRLF in a header value splits the response and lets an attacker inject headers or content (Parsing HTTP).
- Do not serialise internal error objects into the response body: framework version, file paths and query fragments are all useful to an attacker (Not Leaking Your Internals).
- A response written before authorization completes cannot be retracted. Order the pipeline so no bytes are produced until the caller is allowed to receive them (Where the Check Belongs).
- "
req.bodyis part of the request." It is the output of a body-parsing step. Without that step it does not exist, and with it the raw bytes may be gone. - "I can always change the status code in my error handler." Only before the first byte is written. After that the status is on the wire.
- "Returning from the handler sends the response." It queues bytes for a socket. Delivery is the client's business, and it may never happen.
- "
resis just data I return." It is a stream with a state machine. Writing to it twice is not a duplicate value, it is an illegal transition.
Operating it
- Log the correlation id from the request-scoped context on every line the request produces, so all of them can be gathered later (Correlation Ids That Survive Every Hop).
- Count "headers already sent" errors as their own category. They are almost always a control-flow bug rather than a load problem, and they cluster on one route.
- Record response size alongside duration. Large responses explain a whole class of latency that looks like slow application code (What Serialization Costs).
- Track client disconnects during response writes separately from server errors: the same log line reads very differently once you know the client left.
- The objects themselves are cheap; the buffers they reference are not. At high concurrency, per-request memory is response body plus parsed body plus whatever middleware attached, multiplied by in-flight requests.
- At 100x, holding a fully materialised response in memory before writing it becomes the difference between a service that streams and a service that runs out of memory during a traffic spike (Pagination That Survives a Large Table).
- Nothing about the phases changes with scale. What changes is that the "response already sent" race becomes common enough to appear in your logs daily.
- Attaching context to the request object is convenient and untyped: it becomes an unowned grab bag that no one can safely remove a field from. A typed request-scoped context costs ceremony and buys knowing what is in it.
- Buffering the whole response makes
Content-Length, retries and error handling easy, and costs memory proportional to payload size. Streaming inverts both. - Keeping raw bytes for signature verification costs memory on every request of that type, and there is no way to verify a signature without them.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- FRAMEWORK-SPECIFICExpress-style frameworks give you one mutable
req/respair passed down a chain and mutated in place; Fastify wraps the same Node objects with its own request/reply and encourages a typed decorator API; ASP.NET Core and Go'snet/httppass an explicit context value instead. The phase rule — headers before body, once — is common to all of them because it is the protocol. - PROTOCOL-SPECIFICThe "headers cannot change after the first byte" rule comes from HTTP/1.1 writing a status line and headers ahead of the body on the wire. HTTP/2 sends a HEADERS frame followed by DATA frames, and trailers allow a small amount of metadata after the body — but the status is still committed at the same moment.
- LANGUAGE-SPECIFICIn Node, the body is an async stream and forgetting to consume it leaves a connection half-read; in a synchronous Python WSGI app the server hands you a file-like
wsgi.inputand the same "read once" rule applies with completely different-looking symptoms.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.