Engineering Notes
Home/Backend/Frameworks/Request/Response Lifecycle
Backend · Frameworks

Request/Response Lifecycle

From bytes on a socket to a JSON response and back. Follow a request through Node's HTTP parser, the event loop, middleware and your handler, and learn why the body is a stream and where request-scoped data should live.

⚙️ Backend● Intermediate⏱ 5 min read▶ 1 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • From socket to handler and back
  • The Express and NestJS versions
  • The body is a stream; the response side
  • Request-scoped data with AsyncLocalStorage

📚 Before you start

1From Socket to Handler and Back

Every framework request follows the same overall arc, even though the names differ:

text
TCP connection accepted
  → HTTP parser reads request line + headers   (Node's llhttp)
  → framework creates req/res objects
  → global middleware                           (06-middleware/)
  → routing: find the handler                   (04-routing/)
  → route-level middleware / guards / pipes
  → handler (controller)                        (05-controllers/)
  → response serialization (JSON, status, headers)
  → bytes written to the socket
  → "finish" event, logging, metrics

Understanding this order answers most "why didn't X happen?" questions: the body parser hadn't run yet; the guard ran before the pipe; the logger was registered after the router; headers were already sent.

2The Express Version

ts
const app = express();
app.use(helmet());           // 1. security headers on every response
app.use(express.json());     // 2. stream + parse the body into req.body
app.use(requestId);          // 3. attach an id for logs
app.use("/api", authenticate);
app.get("/api/orders/:id", loadOrder, (req, res) => res.json(res.locals.order)); // route + handler
app.use(notFound);           // 4. nothing matched → 404
app.use(errorHandler);       // 5. anything called next(err) → error response

Express has exactly one concept — middleware — and the router itself is middleware. The "lifecycle" is literally the order of app.use/app.METHOD calls.

3The NestJS Version

Nest formalizes the stages so each kind of logic has a fixed slot:

text
Incoming request
  → Middleware            (global, then module-bound)
  → Guards                (global → controller → route)
  → Interceptors, before  (global → controller → route)
  → Pipes                 (global → controller → route → param)
  → Controller method
  → Service(s)
  → Interceptors, after   (route → controller → global — reverse order)
  → Exception filters     (route → controller → global — only if something threw)
  → Response
If you need to...UseBecause it runs...
reject unauthenticated/unauthorized callsGuardbefore any parsing/validation work
validate & transform @Body()/@Param()Piperight before the handler, per argument
time, cache, or reshape the responseInterceptoraround the handler (before and after)
map exceptions to HTTP errorsException filteronly on the error path

4The Body Is a Stream

The request body is not available when the request object is created — it arrives as a stream of chunks after the headers. A body parser must:

  1. check Content-Type (application/json, multipart/form-data, ...)
  2. read chunks until the end, enforcing a size limit (Express default 100kb)
  3. decode and parse, then set req.body and call next()

That's why a handler that reads req.body before a parser has run sees undefined, and why huge uploads are streamed to disk instead (15-file_uploads/).

5The Response Side

A response has three phases and the order is enforced by HTTP itself:

  1. Status line + headers — can be changed freely until the first byte is written.
  2. Body — once writing starts, headers are flushed (res.headersSent === true) and can no longer change.
  3. End — res.end(); Node emits "finish" when the last byte is handed to the OS.
ts
res.status(201);
res.setHeader("Location", `/orders/${order.id}`);
res.json(order);          // serializes, sets Content-Type + Content-Length, writes, ends
res.setHeader("X-Late", "1"); // ❌ ERR_HTTP_HEADERS_SENT

6Where Request-Scoped Data Lives

Data produced early in the lifecycle (the authenticated user, a request ID, a DB transaction) needs to reach code that runs later:

MechanismExampleNotes
request objectreq.user, res.locals.ordersimplest; must be passed down manually
AsyncLocalStorageals.getStore().requestIdavailable anywhere in the same async call chain, even in services
DI request scopeNest @Injectable({ scope: Scope.REQUEST })a new provider instance per request; slower, use sparingly
ts
import { AsyncLocalStorage } from "node:async_hooks";
const als = new AsyncLocalStorage<{ requestId: string }>();

app.use((req, res, next) => als.run({ requestId: crypto.randomUUID() }, next));

function log(msg: string) {
  console.log(`[${als.getStore()?.requestId}] ${msg}`); // works deep inside services
}
Under the HoodNode's HTTP Server and the Event Loop▾

http.createServer(handler) registers a listener for the "request" event. For each connection, Node's C-based llhttp parser consumes bytes from the socket; once the header block is complete it constructs an IncomingMessage (a readable stream — the body hasn't arrived yet) and a ServerResponse (a writable stream), and emits "request". Express's app is that handler function: it wraps req/res with its prototypes and starts the middleware chain.

Everything then runs on one thread. While one handler awaits a database query, the event loop is free to parse and dispatch other requests — which is why a CPU-heavy synchronous handler (say, a 200ms JSON transform) delays every concurrent request, not just its own. Keep-alive lets one TCP connection carry many sequential requests; server.keepAliveTimeout and headersTimeout bound how long idle or slow clients can hold sockets.

AsyncLocalStorage works because Node tracks async resources: every promise, timer and I/O callback created while a store is active inherits that store, so getStore() in a callback many awaits later returns the context of the request that started the chain.

A request flows from socket parsing through middleware, guards, interceptors, pipes and the handler, then back out through interceptors and exception filters before the response is written
A request flows from socket parsing through middleware, guards, interceptors, pipes and the handler, then back out through interceptors and exception filters before the response is written

⚠️ Common Pitfalls

  1. Reading req.body without a body parser (or with the wrong Content-Type) → undefined.
  2. Setting headers after the body has started → ERR_HTTP_HEADERS_SENT.
  3. Validation in a guard, authorization in a pipe — each stage has a job; mixing them makes ordering bugs (e.g. validating a body before rejecting an unauthenticated caller wastes work and leaks validation details).
  4. CPU-bound work inside a handler blocks every other in-flight request (move it to node/09-worker_threads or a background job, 16-background_jobs/).
  5. Not responding on every code path — a branch that neither responds nor throws leaves the request hanging.
  6. Module-level mutable state used as "request state" — shared by every concurrent request; use the request object or AsyncLocalStorage.

📋 Quick Reference

StageExpressNestJS
cross-cutting pre-processingapp.use(mw)middleware
allow/denymiddlewareguard
validate/transform inputmiddleware (zod/joi)pipe
around-the-handler logicres.on("finish")interceptor
business logichandler → servicecontroller → service
errors → responses4-arg error middlewareexception filter
request-scoped datareq, res.locals, ALS@Req(), ALS, request-scoped providers

🧠 Key takeaways

  • Node parses HTTP in C, emits 'request' with req/res streams, and your framework runs on that callback.
  • The request body is a stream; parsers read it fully (enforce size limits) or you stream it.
  • The response is written once headers are sent (headersSent): handle errors before streaming.
  • Store request-scoped data (user, request ID) in req/res.locals or AsyncLocalStorage, never globals.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1Walk through the lifecycle of an HTTP request in a web framework.core▾

Server accepts the connection and parses the request → middleware pipeline (logging, CORS, auth) → routing selects the endpoint → model binding and validation → filters/guards → handler calls services → result serialized → response middleware (compression, headers) → response written → logging/metrics on completion.

Follow-ups interviewers ask
  • Where are errors caught? Global exception middleware/filters around the pipeline.
Q2How is request-scoped context carried through the code?core▾

Via the request object, DI scopes (scoped services), or async context storage: AsyncLocalStorage in Node, AsyncLocal<T>/HttpContext in .NET, contextvars in Python. Used for user identity, correlation IDs and transactions.

Follow-ups interviewers ask
  • Why not globals? Concurrent requests would overwrite each other.
Q3What happens when a client disconnects mid-request?senior▾

The server may keep working unless it observes cancellation: HttpContext.RequestAborted in .NET, req.on('close')/AbortSignal in Node. Propagate cancellation to DB queries and downstream calls to save resources.

Follow-ups interviewers ask
  • Example benefit? Cancelling an expensive report query when the user navigates away.
Q4How does response streaming work?core▾

Instead of buffering the full body, the server sends chunks as they're ready (chunked transfer encoding in HTTP/1.1, frames in HTTP/2): useful for large files, CSV exports and server-sent events. Middleware that buffers (some compression) can break streaming.

Follow-ups interviewers ask
  • Benefit? Lower memory usage and faster time to first byte.
Q5Where should timeouts be configured?core▾

At every layer: load balancer idle timeouts, server request timeouts, handler-level budgets, and client timeouts for downstream calls, ordered so inner timeouts fire before outer ones.

Follow-ups interviewers ask
  • Why order them? So the service returns a meaningful error instead of the LB cutting the connection.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Why must body parsers enforce a size limit?
Memory exhaustion.
2. Where should a per-request correlation ID live?
Concurrency-safe.
3. 'Cannot set headers after they are sent' means…
One response per request.
4. While a handler awaits the DB, the event loop…
Non-blocking I/O.