HTTPFRAMEWORK-SPECIFICPROTOCOL-SPECIFICLANGUAGE-SPECIFIC

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.

What actually happensHow to build it

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.

The question

What is the req object my handler receives, and why does the response sometimes refuse to be changed?

The requirement

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.

The obvious build

req is a plain object holding everything about the request, res is a plain object I fill in and return. Both are just data.

Why it breaks

"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).

How it breaks in production
  • "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.body is 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 req by 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 req and res after the response has been sent, and writes to a socket that is closed.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • req is 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).
  • res is 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.user is 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.body after a body parser has run, req.params after routing has matched, req.query after 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-Length commits 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.

point of no returnclient sees 200 + partial bodyHandler running status + headers mutableFirst byte written (writeHead / implicit)Error here: send a clean 500Body streaming headers frozenend() response completeError here: can only truncate
UserLLMAgentToolDataDecisionHumanGuardrail

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.

Keeping the raw bytes when a signature depends on them
1import { createHmac, timingSafeEqual } from 'node:crypto'
2
3// The body arrives once. If JSON.parse runs first, the exact bytes
4// are gone -- and a re-serialised object is NOT byte-identical
5// (key order, whitespace, number formatting all differ).
6async function readRawBody(req: NodeJS.ReadableStream, limit: number) {
7 const chunks: Buffer[] = []
8 let size = 0
9 for await (const chunk of req) {
10 size += (chunk as Buffer).length
11 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 bytes
21 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 parse
29 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

FRAMEWORK-SPECIFICField names are Express-flavoured; Fastify, Koa, Flask and Go all use different names for the same seven origins. The row that never changes is the last two: anything a framework did not derive from a verified credential is client input, whatever it is called.

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.

FieldWhere it came fromTrust
method, urlThe request line, parsedAttacker-controlled, well-formed
headersThe header block, normalisedAttacker-controlled unless a trusted proxy overwrites it
queryParsed from the URL after routing setupAttacker-controlled; type is always string or array
paramsProduced by the router from the matched pathAttacker-controlled; usually the object-level authz input (Object-Level Authorization)
bodyOutput of a body-parsing step, not intrinsicAttacker-controlled; absent if nothing parsed it
cookiesA header, parsedAttacker-controlled unless signed or encrypted
req.user / req.authAttached by authentication middlewareTrusted, and only as far as the verification was correct
req.correlationIdAttached at the edge or generatedTrusted 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.userId from a verified token, never a userId that could have arrived in the body (The Trust Boundary).
  • Never let a reference to req or res escape the request. Work that outlives the response belongs in a job with its own inputs (Background Jobs).
  • Set Content-Type and, when you know it, Content-Length explicitly. Content sniffing by clients is a source of both bugs and vulnerabilities.

What can go wrong

Failure modes
  • 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 req in 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.
What can race
  • 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.
Security
  • Everything on req that 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).
Misreads
  • "req.body is 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.
  • "res is 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

How you see it in production
  • 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.
What changes at 10x and 100x
  • 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.
What this costs
  • 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/res pair 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's net/http pass 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.input and 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.

OS & Networkingeverything-is-io