The node:http Mental Model
Here's the thing nobody tells you before they hand you Express: Node already has a complete web server,
built right in. No npm install, no dependencies — it's the node:http module, and it can listen on
a port, accept connections, parse requests, and write responses all on its own. Express and Fastify don't
replace it. They wrap it. This is the roots guide, and the payoff is real: once you've built a small
API with only node:http, app.get(...) stops being magic, because you'll have written the thing it's
hiding.
📝 This is a roots guide. It assumes you know JavaScript/Node — functions, callbacks,
async/await(JavaScript From Zero) — and basic HTTP: methods, status codes, headers (HTTP, Explained). It's the JS parallel to the Go net/http roots guide, and it reads best before or alongside Express so you can see exactly what a framework adds. Examples run withnode.
The whole model is one function
Before any code, hold the picture in your head — then the code is just names attached to ideas you already
have. The entire node:http server is this: you create a server and hand it one function. Node calls
that function — the request listener — once for every request that arrives. Its signature is
(req, res): req is the incoming request, res is the response you're going to write back.
That's the whole architecture, and here's the sentence to carry through the rest of the guide:
💡 createServer calls your (req, res) listener for every request; routing and middleware are code
you write. There is no built-in router that maps /messages to a function. There is no built-in
middleware chain. Node hands you the raw request and a blank response, and the rest is yours. (Don't worry
— you'll build a router in Phase 3 and middleware in Phase 4, and they're smaller than you'd think.)
flowchart LR
C[Client] -->|HTTP request| S[http server]
S -->|calls listener| L["your (req, res) function"]
L -->|writeHead + write + end| R[HTTP response]
R --> C
What just happened: a client sends a request; the server Node built for you accepts it and calls your
one listener with two arguments. Your function reads what it needs from req, sets a status and headers on
res, writes a body, and ends the response. Node ships it back. Notice there's nothing between the server
and your function — no routing layer deciding which function to call, because there's only ever one.
Branching to different behavior per URL is something you add.
The smallest server that works
Now the picture in code. This is a complete Node program — a server that answers every request with a line of plain text.
const http = ;
const server = http.;
server.;
What just happened: four moving parts, top to bottom —
require('node:http')pulls in the built-in module. Thenode:prefix says "this is a core module, not a package fromnode_modules" — no install needed, it's part of Node.http.createServer((req, res) => { ... })builds the server and registers your request listener in one move. That arrow function is the(req, res)function from the mental model — Node will call it for every incoming request.createServerreturns the server; it doesn't start it yet.- Inside the listener,
res.writeHead(200, { ... })sets the status code (200 = OK) and the response headers — here, telling the client the body is plain text. Thenres.end('Hello from node:http')writes the body and closes the response. server.listen(3000, ...)is what actually starts the server: bind to port 3000 and begin accepting connections. The callback runs once, when the server is up — handy for a "ready" log.
⚠️ You must call res.end(). It's the signal that the response is complete — without it, Node keeps the
connection open waiting for more, and the client sits there spinning until it times out. A request that
"hangs forever" in Node is, nine times out of ten, a code path that forgot to call res.end(). Make ending
the response a reflex.
Run it and hit it from another terminal:
# in another terminal:
# Hello from node:http
What just happened: node server.js starts the program; it stays running, holding port 3000, because
server.listen keeps the process alive. curl opens a connection and sends GET /, Node calls your
listener with that request, your function writes the headers and body and ends, and curl prints what came
back. That's a web server with zero dependencies.
req and res: two streams, opposite directions
Those two arguments aren't plain objects with all the data sitting ready inside them. They're streams — and which direction they flow is the key to understanding them.
📝 req is an IncomingMessage, and it's a readable stream — data flows from the client to
you. Some of it is available immediately as properties: req.method ('GET', 'POST', ...), req.url
(the path and query string, like /messages?limit=10), and req.headers (an object of the request
headers). But the body — the JSON a client POSTs, say — isn't a property. It arrives as a stream of
chunks you read over time. That's why reading a request body takes a few lines instead of one; Phase 2 is
where we do it properly.
📝 res is a ServerResponse, and it's a writable stream — data flows from you to the client.
You set the status and headers (res.writeHead(...), or res.statusCode / res.setHeader(...)), then you
res.write(...) body chunks if you want, and finally res.end(...) to flush and close. res.end() can
also take a final chunk, which is why the tiny server above wrote its whole body in one end() call.
💡 The stream nature matters more than it looks right now. It's why Node can start sending a response before
the whole thing is built, and why it can handle a huge upload without loading it all into memory. We lean on
that in Phase 2 (reading bodies) and again in Phase 6 (streaming responses). For now, the takeaway is just
the shape: req carries the request in, res carries the response out, and both are streams.
Where the frameworks fit — and what we'll build
Here's the reveal that justifies the whole guide. When you reach for Express later, you are not escaping
node:http — you're sitting on top of it.
💡 Express, Fastify, and friends are conveniences over exactly this (req, res) model. Under the hood,
an Express app is a request listener you hand to http.createServer; app.get('/messages', ...) is
their router doing the req.method / req.url switch you'd otherwise write by hand, and app.use(...) is
their formalized version of "call this function before the handler." Nicer ergonomics, real conveniences —
but the request still enters through a server, and something still writes to res. The skeleton is the one
you just met. (Express From Zero walks that mapping in full.)
To keep this concrete instead of abstract, the rest of the guide builds one small thing the whole way through: a messages service. The data is deliberately tiny — each message is just an object:
const message = ;
What just happened: nothing yet — that's only the shape of the data our API will serve. Over the next
phases we'll read requests and write it back as JSON (Phase 2), route by method and path (Phase 3), wrap
handlers with middleware (Phase 4), and grow it into a full CRUD REST API with no framework at all
(Phase 5). Every step is the same one idea: Node calls your (req, res) function, and you do the rest.
Recap
- Node ships a complete HTTP server in the built-in
node:httpmodule — no install, no dependencies. Express and Fastify are conveniences layered over it. http.createServer((req, res) => {...})builds a server and registers your single request listener; Node calls that one function for every request.server.listen(port)starts it.- The mental model, all the way down:
createServercalls your(req, res)listener per request, and routing plus middleware are code you write — there is no built-in router or middleware. req(IncomingMessage) is a readable stream carrying the request in —req.method,req.url,req.headers, and a body that streams in chunks.res(ServerResponse) is a writable stream you set a status/headers on, then write and end.- ⚠️ You must call
res.end(), or the request hangs until it times out — ending the response is the "I'm done" signal. - We'll build a messages service (
{ id, text }) on the bare standard library across the guide.
Quick check
Three questions on the ideas that have to stick before Phase 2:
[
{
"q": "What does http.createServer take as its argument?",
"choices": [
"One request listener function, (req, res), that Node calls for every request",
"A list of routes mapping URLs to handlers",
"A middleware chain to run in order",
"The port number to listen on"
],
"answer": 0,
"explain": "createServer takes a single (req, res) listener and returns a server. Node calls that one function for every incoming request. There is no built-in routing or middleware — you write those yourself. The port goes to server.listen(), not createServer."
},
{
"q": "Why might a node:http request 'hang forever' with no response?",
"choices": [
"The handler never called res.end(), so Node keeps the connection open",
"createServer was given two listeners instead of one",
"req is a writable stream and can't be read",
"The status code was set to 200 instead of 204"
],
"answer": 0,
"explain": "res.end() signals that the response is complete. If a code path forgets to call it, Node holds the connection open waiting for more, and the client spins until it times out. Always end the response."
},
{
"q": "What are req and res in the request listener?",
"choices": [
"req is a readable stream (the request in); res is a writable stream (the response out)",
"Both are plain objects with all data preloaded as properties",
"req is writable and res is readable",
"They are the same object passed twice for convenience"
],
"answer": 0,
"explain": "req (IncomingMessage) is readable — method/url/headers are properties, and the body streams in as chunks. res (ServerResponse) is writable — you set status and headers, then write and end. Request flows in, response flows out."
}
]
Guide overview · Phase 2: Handling Requests & Responses →
Check your understanding
1. What does http.createServer take as its argument?
2. Why might a node:http request 'hang forever' with no response?
3. What are req and res in the request listener?