The Node.js HTTP Server Model
Every HTTP framework you'll use in Node - Express, Fastify, NestJS - is a layer of conveniences wrapped around the same primitive: the node:http module.
Search across all documentation pages
Every HTTP framework you'll use in Node - Express, Fastify, NestJS - is a layer of conveniences wrapped around the same primitive: the node:http module.
That module is not a framework itself.
It's a thin, deliberately low-level translation of raw TCP bytes into two JavaScript objects - a request and a response - and it leaves almost everything else (routing, parsing, validation) for something else to add.
Understanding this layer matters even if you never write http.createServer by hand, because the behavior you'll debug in a framework - a hung response, a header-ordering error, a slow-client timeout - is almost always a symptom from this layer leaking through.
HTTP Basics in Node gives you hands-on examples of this module; this page is the model underneath those examples - what a "request" and "connection" actually are, and how frameworks sit on top without replacing any of it.
http module turns raw TCP socket data into a stream-based IncomingMessage (the request) and ServerResponse (the response), and every framework in this category runs as the callback wired to that same low-level request event.IncomingMessage, ServerResponse, keep-alive.A connection and a request are not the same thing, and conflating them is the single most common source of confusion at this layer.
A connection is a TCP socket - a raw, persistent pipe between a client and the server.
A request is one HTTP message that travels over that socket.
Thanks to HTTP/1.1 keep-alive, a single socket routinely carries many requests, one after another, without reopening the connection each time - which is why server-side connection limits and per-request limits are genuinely different knobs.
When a client opens a connection and sends a well-formed HTTP message, Node's built-in HTTP parser (llhttp) turns the raw bytes into two objects and fires a request event on the server: an IncomingMessage and a ServerResponse.
import { createServer } from "node:http";
const server = createServer((req, res) => {
// req: IncomingMessage - a readable stream of the request body
// res: ServerResponse - a writable stream you send the reply through
res.end("ok");
});Both objects are streams before they are anything else.
IncomingMessage is a readable stream - the body arrives in chunks, not as a single string, because Node has no idea in advance how large a request body will be.
ServerResponse is a writable stream - calling res.write() sends bytes immediately if headers have already gone out, or queues them until they do.
This stream-first design is why frameworks that "parse JSON automatically" are really just doing the chunk-collection work shown in HTTP Basics in Node for you, behind a .json() call.
The path from a client's TCP packet to your handler function has a fixed shape: the OS hands an accepted connection to net.Server, http.Server attaches its parser to that socket, the parser incrementally decodes bytes into HTTP semantics (method, path, headers, body chunks), and once a complete request line and headers have arrived, Node fires request with your IncomingMessage/ServerResponse pair.
Nothing about this path is unique to Express or Fastify - both literally call http.createServer(listener) (or an equivalent) under the hood, and pass their own listener function as the callback.
TCP socket accepted
│
▼
HTTP parser (llhttp) decodes bytes incrementally
│
▼
'request' event fires ──────────────► framework's listener runs
│ (Express app, Fastify router, ...)
▼
body arrives as stream chunks over time, independent of the event aboveWhat differs between frameworks is entirely what happens inside that listener function - Express walks a middleware chain, Fastify dispatches through a compiled router with lifecycle hooks, NestJS resolves a module graph before delegating to whichever adapter it's configured with.
None of them get a different request object from the OS; they all decorate or wrap the same IncomingMessage/ServerResponse pair Node handed them.
That's a useful debugging heuristic: if something breaks identically across every framework you try, the bug is very likely at this layer, not theirs.
Headers deserve special attention because Node enforces an ordering rule that trips up raw-module and framework code alike: res.writeHead() sends status and headers together, immediately, while res.setHeader() only queues a header to be sent whenever the first bytes actually go out (either via the first write() or via end()).
Once headers have been sent - by either method - any further attempt to set them throws ERR_HTTP_HEADERS_SENT, which is exactly what happens when a handler double-responds after a framework already sent an error page on its behalf.
At scale, this layer is where connection-level failure modes live, and they don't disappear just because you've added a framework on top.
A slow or malicious client that opens a connection and trickles bytes can hold a socket (and the handler waiting on it) open far longer than a legitimate request would - server.requestTimeout and server.headersTimeout exist specifically to bound that, and every production framework deployment should set them explicitly rather than trust the (historically permissive) defaults.
Reverse proxies change what "the client" even means at this layer: a load balancer or CDN terminates the real client connection and opens its own connection to your Node process, so req.socket.remoteAddress reports the proxy's IP unless you deliberately trust and parse X-Forwarded-For - see Reverse Proxy Awareness for the details.
HTTP/2 and HTTP/3 change more of this model than most developers expect: HTTP/2 multiplexes many logical requests over a single TCP connection using stream IDs, which breaks the simple "one socket roughly equals one client's traffic" intuition this page has been building - see HTTP/2 & HTTP/3 Considerations.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
Raw node:http | No dependency overhead; full control over every byte | No routing, parsing, or validation - you build it all | Health-check sidecars, tiny internal tools, learning the model |
Express on http | Huge ecosystem; simple mental model on top of this layer | Flat middleware chain has a real performance ceiling at high throughput | Most CRUD APIs, teams prioritizing familiarity |
Fastify on http | Schema-compiled serialization; encapsulated plugin structure | More upfront structure than a small app may need | High-throughput APIs, schema-first teams |
| NestJS (adapter over Express/Fastify) | DI-driven architecture at scale; framework-agnostic transport | Heaviest abstraction over this layer; steepest learning curve | Large teams, long-lived enterprise services |
Observability at this layer is cheap and often skipped: the request, close, and clientError events on http.Server let you measure connection-level health (malformed requests, abrupt disconnects) independently of whatever your framework's own logging does, which is useful precisely because it isn't filtered by framework-level routing logic.
keepAliveTimeout) and request-level settings (like requestTimeout) govern different things.http module." They wrap it - every Express, Fastify, or NestJS-on-Express app is still one http.createServer call with a more elaborate listener function inside.req.body in Express) is a framework buffering and parsing those chunks for you first.res.end() is optional if you've already called res.write()." The client keeps waiting until end() is called (or the connection times out) - a response is not "sent" until it's explicitly closed.write() or end()), further header changes throw ERR_HTTP_HEADERS_SENT.Node's built-in translation layer between raw TCP socket bytes and two stream-based JavaScript objects - an IncomingMessage (request) and a ServerResponse (response) - that every HTTP framework builds its own abstractions on top of.
No. Express and Fastify both call http.createServer() (or the HTTPS/HTTP2 equivalent) directly; NestJS delegates to whichever adapter you configure, which is itself Express or Fastify underneath. They all receive the same IncomingMessage/ServerResponse pair from Node.
Node has no way to know a body's total size in advance, so it delivers it as a readable stream of chunks as they arrive over the network. req.body only exists because a framework (or your own code) collected and parsed those chunks first.
The TCP socket stays open after a response finishes, so the next request from the same client can reuse it instead of paying the cost of a new TCP handshake. server.keepAliveTimeout controls how long Node holds that idle socket open waiting for a next request.
Almost always a missing or unreachable res.end() - a response stream that's never explicitly closed leaves the client (and the connection) waiting indefinitely, or until a timeout intervenes.
writeHead() sends status and headers immediately, as a single action. setHeader() only queues a header value to be included whenever the response actually starts flushing - the first write() or the end() call, whichever comes first.
Because a reverse proxy or load balancer usually terminates the real client's connection and opens its own connection to your Node process - so the socket-level address is the proxy's, not the original client's, unless you explicitly trust and parse a forwarded-for header.
Yes, for small, single-purpose services - health-check endpoints, internal sidecars, or anything where routing and body parsing add more overhead than value. Most application APIs still benefit from a framework's structure once they grow past a handful of routes.
It multiplexes multiple logical request/response exchanges over a single TCP connection using stream IDs, instead of one request finishing before the next begins - which breaks the simple "one connection roughly maps to one request at a time" intuition this page builds around HTTP/1.1.
That code somewhere tried to set a header or status code after the response had already started sending - usually a sign of a handler running twice, or a fallback error path executing after a normal response already completed.
No - they all read the same underlying stream, but differ in defaults (size limits, content-type handling) and in when parsing happens in the pipeline. Express requires express.json() explicitly; Fastify includes JSON parsing by default with schema-aware limits.
To bound how long a slow or misbehaving client can hold a connection (and the server resources behind it) open, rather than relying on defaults that historically allowed near-unbounded waits - a real vector for connection exhaustion under load.
createServer, headers, and streamingStack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, and TypeScript 5.6+.
Reviewed by Chris St. John·Last updated Jul 15, 2026