# Build a Server With Only node:http

> Build a real web server using nothing but Node's built-in http module: the server/request/response model, reading requests and writing JSON, routing by hand, middleware as plain functions, a full REST API with no framework, async and streams and structure, and exactly what Express adds on top. The foundation every Node framework is built on.


---

# Build a Server With Only node:http

Before you reach for Express or Fastify, it's worth knowing this: Node ships with everything you need to
run a real web server in its built-in **`node:http`** module. Express and Fastify are conveniences *over
this* - and once you've built a JSON API with only the standard library, every Node framework reads as
"`node:http` with the boilerplate removed." This is the **roots** guide: learn it and `app.get(...)` stops
being magic, because you'll have written the thing it wraps.

The mental model is small. **`http.createServer`** gives you a server; you hand it one **request listener**
 - a function `(req, res)` called for every request. `req` is a readable stream of the incoming request
(method, url, headers, body); `res` is a writable stream you set a status + headers on and write the
response to. There's no router and no middleware - you write a router by switching on `req.method` and
`req.url`, and "middleware" is just a function you call before your handler. Hold "a server calls your
`(req, res)` function for each request, and you do the rest," and the whole Node web stack opens up.

> 📝 This is a **roots** guide - it assumes **JavaScript**/Node: functions, `async`/`await`, callbacks,
> streams basics ([JavaScript From Zero](/guides/javascript-from-zero)) and basic **HTTP**
> ([HTTP, Explained](/guides/http-explained)). It's the JS parallel to the Go
> [net/http roots guide](/guides/web-services-with-only-net-http) and is best read before or alongside
> [Express](/guides/express-from-zero) so you can see what it adds. Examples run with `node`.

## How to read this

Short and foundational - read in order. It builds a bare server, then a full JSON API (a small **messages**
service), then maps it onto Express. Phases carry difficulty badges.

## The phases

1. **[The node:http Mental Model](01-the-mental-model.md)** 🟢 - `createServer`, the `(req, res)` listener, and how a request flows.
2. **[Handling Requests & Responses](02-requests-and-responses.md)** 🟡 - method/url/headers, reading the body, and writing JSON with a status.
3. **[Routing by Hand](03-routing-by-hand.md)** 🟡 - switching on method + path, and why a real router exists.
4. **[Middleware Is Just a Function](04-middleware-is-a-function.md)** 🟡 - wrapping handlers, and the chain Express formalizes.
5. **[A JSON REST API With No Framework](05-rest-api-no-framework.md)** 🔴 - full CRUD for the messages resource, standard library only.
6. **[Async, Streams & Structure](06-async-streams-structure.md)** 🔴 - promises in handlers, streaming, graceful shutdown, and layout.
7. **[What Express Adds](07-what-express-adds.md)** 🟢 - mapping Express back onto this, and when you don't need a framework.

> The throughline: **`http.createServer` calls your `(req, res)` function per request; routing and
> middleware are code you write.** That's `node:http`, and it's the skeleton inside every Node framework.


---

# The node:http Mental Model

Node already has a complete web server built 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. Build a small API with
only `node:http` and `app.get(...)` stops being magic - 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](/guides/javascript-from-zero)) - and basic **HTTP**: methods,
> status codes, headers ([HTTP, Explained](/guides/http-explained)). It's the JS parallel to the Go
> [net/http roots guide](/guides/web-services-with-only-net-http), and it reads best before or alongside
> [Express](/guides/express-from-zero) so you can see exactly what a framework adds. Examples run with `node`.

## The whole model is one function

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.

Carry this sentence 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.)

```mermaid
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. There's nothing between the server and
your function - no routing layer deciding *which* function to call, because there's only ever one.
Branching per URL is something you add.

## The smallest server that works

A complete Node program - a server that answers every request with a line of plain text.

```javascript
const http = require('node:http');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Hello from node:http');
});

server.listen(3000, () => console.log('listening on http://localhost:3000'));
```

*What just happened:* four moving parts, top to bottom - 

- `require('node:http')` pulls in the built-in module. The `node:` prefix says "this is a core module, not
  a package from `node_modules`" - no install needed.
- `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 calls it for
  every incoming request. `createServer` *returns* 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. Then `res.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()`.

Run it and hit it from another terminal:

```bash
node server.js
# in another terminal:
curl localhost:3000
# 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 - 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
`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: **`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`. ([Express From
Zero](/guides/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:

```javascript
const message = { id: 1, text: 'Hello from node:http' };
```

*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

1. Node ships a complete HTTP server in the built-in **`node:http`** module - no install, no dependencies.
   Express and Fastify are conveniences layered over it.
2. **`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.
3. The mental model, all the way down: **`createServer` calls your `(req, res)` listener per request, and
   routing plus middleware are code you write** - there is no built-in router or middleware.
4. **`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.
5. ⚠️ You must call **`res.end()`**, or the request hangs until it times out - ending the response is the
   "I'm done" signal.
6. 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:

```quiz
[
  {
    "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."
  }
]
```


---

# Handling Requests & Responses

In [Phase 1](01-the-mental-model.md) you stood up a server and watched it call your `(req, res)`
function for every request. That function is the whole job - everything a web server does, reading what
came in and deciding what to send back, happens inside it. Let's get specific about the two objects
you've been handed.

## The mental model: read from `req`, write to `res`

Here's the picture to carry through this phase:

- **`req` is the incoming request.** You *read* from it: the method, the URL, the headers, and - the
  awkward one - the body. It's a **readable stream**, which matters for the body and nothing else.
- **`res` is the outgoing response.** You *write* to it: a status code, some headers, and a body.
  It's a **writable stream**.

That's the entire dance. A handler reads `req` and writes `res`. There is no built-in "give me the
JSON body" and no built-in "send this object as JSON" - both directions are manual, and in this phase
you'll write the two small helpers that do them. When you later see `express.json()` and `res.json()`,
you'll recognize them as exactly these helpers, pre-installed.

> 📝 We're building a **messages** service throughout this guide - a list of `{ id, text }` objects.
> This phase is about the plumbing for one request; [Phase 5](05-rest-api-no-framework.md) wires it
> into full CRUD. For now, focus on getting data *in* and JSON *out*.

## Reading the request: method, URL, headers

The easy parts come for free as plain properties on `req`:

```javascript
const http = require('node:http');

const server = http.createServer((req, res) => {
  console.log(req.method);           // 'GET', 'POST', 'DELETE', ...
  console.log(req.url);              // '/messages?limit=5'
  console.log(req.headers['host']); // 'localhost:3000'

  res.end('ok');
});

server.listen(3000);
```

*What just happened:* `req.method` and `req.url` tell you what the client asked for, and `req.headers`
is a plain object of every header (keys are lowercased for you - always `req.headers['host']`, never
`'Host'`). No parsing required; these are populated the moment your function runs.

One trap: `req.url` is **not** a tidy path. It's everything after the host - path *and* query string
smooshed together, like `/messages?limit=5`. Picking it apart by hand with string splits is
error-prone, so don't. Node ships the `URL` class for exactly this:

```javascript
const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');

  console.log(url.pathname);                  // '/messages'
  console.log(url.searchParams.get('limit')); // '5'  (a string, or null if absent)

  res.end('ok');
});
```

*What just happened:* `new URL(...)` needs a full absolute URL, but `req.url` is only a path - so we
hand it a throwaway base of `'http://localhost'` just to satisfy the parser. We never use that base
for anything; we only read `url.pathname` (the clean route) and `url.searchParams` (a tiny key/value
API over the query string). `searchParams.get` always returns a string or `null`, so remember to
convert when you want a number: `Number(url.searchParams.get('limit'))`.

## ⚠️ Reading the body: it arrives as a stream, not a string

This is the part that surprises people coming from frameworks. When a client `POST`s JSON, **the body
is not sitting on `req` waiting for you.** `req` is a readable stream, and the body shows up in pieces
("chunks") over time. You have to listen for those chunks, stitch them together, and only *then* parse.

Here's the helper that does it - read it slowly, it's the heart of this phase:

```javascript
function readJson(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', chunk => { body += chunk; });
    req.on('end', () => {
      try {
        resolve(body ? JSON.parse(body) : {});
      } catch (e) {
        reject(e);
      }
    });
    req.on('error', reject);
  });
}
```

*What just happened:* we wrap the stream in a Promise so callers can `await readJson(req)` instead of
juggling events. The `'data'` event fires once per chunk and we append each to a string. The `'end'`
event fires when the body is fully received - that's where we parse, defaulting to `{}` if the body
was empty (a body-less POST shouldn't crash). `JSON.parse` sits inside a `try/catch` because a client
can send garbage, and a parse error should `reject` cleanly rather than throw out of the event callback
where nothing can catch it. The `'error'` event handles the stream itself dying mid-transfer.

> 💡 This helper *is* `express.json()`. When you write `app.use(express.json())` in Express, this exact
> collect-chunks-then-parse logic runs before your route, and the result lands on `req.body`. The
> framework didn't invent a feature - it bundled this boilerplate so you stop rewriting it.

Using it in a handler:

```javascript
const server = http.createServer(async (req, res) => {
  if (req.method === 'POST') {
    let data;
    try {
      data = await readJson(req);
    } catch {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Invalid JSON' }));
      return;
    }
    console.log('client sent:', data.text);
  }
  res.end('ok');
});
```

*What just happened:* the handler is now `async` so we can `await` the body. If `readJson` rejects - 
malformed JSON, a broken connection - we catch it and answer **400 Bad Request** instead of letting the
whole server crash. Notice the `return` after sending the error: without it, execution falls through
and tries to respond a second time, which throws (more on that ordering rule next).

⚠️ One more guard for the real world: this helper appends every chunk with no limit, so a malicious
client could stream gigabytes and exhaust your memory. In production you'd cap `body.length` and
`reject` once it crosses a threshold (a few hundred KB is plenty for JSON). Express's `json()` does
this too, via its `limit` option.

## Writing JSON: status, headers, body - in that order

Sending a response is three moves: set the status and headers, serialize your data, end the stream.
Here's the companion helper:

```javascript
function sendJson(res, status, data) {
  res.writeHead(status, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data));
}
```

*What just happened:* `res.writeHead(status, headers)` sets the status line and headers in one call.
`JSON.stringify(data)` turns your object into the wire format, and `res.end(...)` writes that string
and closes the response. We set `Content-Type: application/json` so the client knows it's getting
JSON, not plain text. Now `sendJson(res, 200, { messages: [...] })` replaces four lines with one.

⚠️ **Order is not optional.** Headers and status must be set *before* you write any body. The first
`res.write()` or `res.end()` "flushes the head" - sends the status line and headers down the wire, and
after that they're locked. Try to set a header afterward and Node throws the error every Node dev meets
eventually:

```javascript
// WRONG - throws "Cannot set headers after they are sent to the client"
res.end(JSON.stringify({ ok: true }));   // body goes out, head is now flushed
res.writeHead(200);                      // too late - head already left the building
```

*What just happened:* `res.end(...)` already committed the status and headers, so the later
`writeHead` has nothing to write into. The fix is always the same: `writeHead` (or `setHeader`) first,
body last. Hitting this is almost always a missing `return` after an early response - two code paths
both trying to answer the same request.

> 💡 `res.setHeader('X', 'y')` sets one header at a time and can be called repeatedly *before* the
> first write; `res.writeHead(status, {...})` sets the status plus a batch of headers in one shot.
> Same rule binds both: nothing after the body starts flowing.

### Status codes

The status code is just a number, and you can pass it straight to `writeHead`. The handful you'll use
constantly for a JSON API:

- **200** OK - a successful GET.
- **201** Created - you made a new resource (a fresh message).
- **204** No Content - success, but there's nothing to send back (e.g. a delete).
- **400** Bad Request - the client sent something wrong (that invalid JSON).
- **404** Not Found - no such route or resource.

If you'd rather not memorize numbers, `node:http` ships `http.STATUS_CODES` - a lookup from number to
its text, e.g. `http.STATUS_CODES[404]` is `'Not Found'`. Handy for building a generic error
responder.

**204 is the special one** - "No Content" means literally no body, so you set the status and end
immediately, writing nothing:

```javascript
function sendNoContent(res) {
  res.writeHead(204);
  res.end();          // no argument - no body, by definition
}
```

*What just happened:* a 204 promises an empty body, so we call `res.end()` with no argument - no
`Content-Type`, no stringify, nothing to describe. The right answer for a successful `DELETE
/messages/3`: it worked, and there's nothing meaningful to return.

## Recap

- A handler **reads from `req`** (method, URL, headers, body-stream) and **writes to `res`** (status,
  headers, body). Both are streams; JSON is manual in both directions.
- `req.method`, `req.url`, and `req.headers` are free properties. Parse `req.url` with
  `new URL(req.url, 'http://localhost')` to get `pathname` and `searchParams`.
- The body is **not** on `req` - it streams in as chunks. Collect them on `'data'`, parse on `'end'`,
  and guard invalid JSON with `try/catch`. That collect-and-parse helper is what `express.json()` does.
- Writing JSON is set-header, set-status, serialize, end - and **headers/status must come before any
  body write**, or you get "Cannot set headers after they are sent." A stray missing `return` is the
  usual culprit.
- Reach for the right status: 200/201/400/404, and **204 means no body at all** (`writeHead(204)` then
  `res.end()`).

## Quick check

```quiz
[
  {
    "q": "Why can't you read the request body directly off req.body in node:http?",
    "choices": ["req.body only works for GET requests", "req is a readable stream - the body arrives as chunks you must collect, then parse", "You must call req.parse() first", "node:http strips the body for security"],
    "answer": 1,
    "explain": "req is a readable stream. You listen for 'data' chunks, concatenate them, and parse on 'end'. That collect-and-parse work is exactly what express.json() bundles for you."
  },
  {
    "q": "You call res.end(JSON.stringify(data)) and then res.writeHead(200). What happens?",
    "choices": ["It works fine", "Node throws 'Cannot set headers after they are sent' - the first write already flushed the head", "The status silently defaults to 500", "writeHead overrides the body"],
    "answer": 1,
    "explain": "The first res.write/res.end flushes the status and headers. After that they're locked, so a later writeHead throws. Set status/headers before writing the body - a missing return is the usual cause."
  },
  {
    "q": "A successful DELETE /messages/3 has nothing to return. What's the right response?",
    "choices": ["200 with an empty {} body", "404 Not Found", "204 with writeHead(204) and res.end() - no body", "201 Created"],
    "answer": 2,
    "explain": "204 No Content means success with nothing to send. You set the status and call res.end() with no argument - no Content-Type, no stringify, no body."
  }
]
```


---

# Routing by Hand

Here's the thing nobody tells you when you first open `node:http`: there is no router. None. When a request arrives, Node hands your one `(req, res)` listener the whole thing and says "you figure out what they wanted." `app.get('/messages')` doesn't exist yet - *you* are the routing layer.

So let's build the right mental model first. **Routing is you reading two facts off the request - the method (`GET`, `POST`, …) and the URL path - and deciding which function should run.** A route is the pair *(method, path)*; routing is the code that maps that pair to a handler. Express, Fastify, Koa - they all eventually do this exact thing under the hood. We're about to write the thing they wrap, and once you've felt the friction by hand, every router you ever use will make sense.

We're continuing the **messages** service from the earlier phases - each message is just `{ id, text }`.

## The dispatch listener

In [Phase 2](02-requests-and-responses.md) you learned to parse the URL and write JSON. Now we use both to dispatch. The simplest router is a ladder of `if` checks:

```javascript
import http from 'node:http';

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  const path = url.pathname;

  if (req.method === 'GET' && path === '/messages') return listMessages(req, res);
  if (req.method === 'POST' && path === '/messages') return createMessage(req, res);

  const idMatch = path.match(/^\/messages\/(\d+)$/);     // path params by regex
  if (req.method === 'GET' && idMatch) return getMessage(req, res, Number(idMatch[1]));

  sendJson(res, 404, { error: 'Not Found' });            // fallthrough
});

server.listen(3000);
```

*What just happened:* we built a `URL` object for a clean `pathname` (no query string, no surprises), then checked method + path together, in order, top to bottom. The first matching `if` calls its handler and `return`s - that `return` is load-bearing, stopping us from falling through to the next check or the 404. Anything that matches nothing drops to the bottom and gets a `404 Not Found`, your safety net.

📝 Notice the pattern: every route is *method AND path*. `GET /messages` and `POST /messages` share a path but are different routes - the method is half the identity. Forget that and your "list" handler will try to run when someone POSTs.

## Path parameters: where it gets fiddly

Look closely at the third route - `/messages/:id`, the "get one message by its id" route. Node has **no built-in support** for path parameters. There's no `:id` placeholder, no params object handed to you. You match the shape yourself with a regular expression and pull the value out of a capture group:

```javascript
const path = '/messages/42';

const match = path.match(/^\/messages\/(\d+)$/);
//                         ^      ^      ^   ^
//                         |      |      |   end-of-string anchor
//                         |      |      capture group: one-or-more digits
//                         |      literal "/messages/"
//                         start-of-string anchor

if (match) {
  const id = Number(match[1]);   // match[0] is the whole match, match[1] is the group
  console.log(id);               // 42
}
```

*What just happened:* the regex says "from start to end, match the literal `/messages/` followed by one or more digits, and capture those digits." `match[1]` holds the captured group (`"42"`), which we convert to a number. The `^` and `$` anchors matter more than they look - without them, `/messages/42/extra` or `/oops/messages/42` would sneak through.

⚠️ This is where hand-rolled routing starts to hurt. Want `/messages/abc` to return a clean `400` instead of silently not matching? More regex. Want `/users/:userId/messages/:msgId`? Now you're juggling two capture groups and remembering which index is which. Want optional trailing slashes? Another branch. Every URL shape you support is another fiddly, error-prone pattern to maintain by hand - exactly why routers exist. When you later write `app.get('/messages/:id', handler)` and just read `req.params.id`, remember: a router is doing this regex dance for you.

## When the ladder stops scaling

Two or three routes? The `if`-ladder is genuinely fine - don't over-engineer it. But watch what happens as the service grows: ten routes, twenty, each with its own method check and maybe a regex, all in one giant function. It becomes hard to read, easy to mis-order, and easy to forget a `return`.

The natural next move is to pull the routes into a **dispatch table** keyed by `"METHOD path"`:

```javascript
const routes = {
  'GET /messages': listMessages,
  'POST /messages': createMessage,
};

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  const key = `${req.method} ${url.pathname}`;

  const handler = routes[key];
  if (handler) return handler(req, res);

  sendJson(res, 404, { error: 'Not Found' });   // nothing matched
});
```

*What just happened:* Instead of a ladder, we build one lookup key per request - `"GET /messages"` - and check the table. If there's a handler, we call it; otherwise, 404. It's flatter and easier to scan. But notice the catch: a plain object key is a fixed string, so this clean version only handles **static** paths. The moment you need `/messages/:id`, the table has to store patterns and loop over them with regex matching - and at that point you're writing pattern compilation, param extraction, and match ordering. You're reinventing a router.

📝 One more thing the hand-rolled versions almost always get wrong: the difference between 404 and 405. A `404 Not Found` means "that path doesn't exist here." But if the path *does* exist and only the **method** is wrong - say someone sends `DELETE /messages` when you only support `GET` and `POST` - the correct answer is `405 Method Not Allowed`, ideally with an `Allow` header listing what's permitted. Doing this by hand means checking "did the path match but the method didn't?" before falling to 404 - extra bookkeeping that's tedious enough that most hand-rolled servers skip it and return a misleading 404.

## This friction is the whole point

Step back and notice what just happened across this phase. We wanted three routes and ended up hand-writing: method checks, URL parsing, regex path matching, capture-group extraction, a dispatch structure, and the 404/405 distinction. None of it is hard in isolation. All of it is repetitive, and all of it is easy to get subtly wrong.

💡 That accumulated friction is *exactly* the gap a router fills. When you reach for [Express](/guides/express-from-zero) next, `app.get('/messages/:id', handler)` collapses everything in this phase into one line - method, path, param extraction, not-found fallthrough - because Express wrote the regex dance once so you never have to. You're not learning Express to avoid understanding routing; you're learning it *because* you now understand routing and know what it's doing for you.

## Recap

- `node:http` ships **no router** - your `(req, res)` listener inspects `req.method` and the parsed URL path and dispatches to a handler itself.
- A route is the pair *(method, path)*; the simplest router is an ordered `if`-ladder where each match `return`s, with a `404` fallthrough at the bottom.
- **Path parameters have no built-in support** - you match them with an anchored regex like `/^\/messages\/(\d+)$/` and read the capture group, which is fiddly and error-prone.
- A dispatch table keyed by `"METHOD path"` reads better for static routes, but adding params forces regex-pattern matching - at which point you're reinventing a router.
- A correct server returns `404` for unknown paths and `405 Method Not Allowed` (with an `Allow` header) for a known path hit with the wrong method - the latter is often skipped in hand-rolled code.
- This exact friction is why frameworks like [Express](/guides/express-from-zero) exist.

## Quick check

```quiz
[
  {
    "q": "In node:http, how does a request get matched to the right handler?",
    "choices": ["A built-in router parses the route table for you", "Your (req, res) listener inspects req.method and the URL and dispatches itself", "Node calls a separate function per HTTP method automatically", "You register routes with app.get() and Node wires them up"],
    "answer": 1,
    "explain": "node:http has no router. Your single listener reads the method and path and decides which handler to call - routing is code you write."
  },
  {
    "q": "Why are path parameters like /messages/:id awkward in node:http?",
    "choices": ["Node forbids numbers in URLs", "There's no built-in support, so you match with a regex and pull the value from a capture group", "You must restart the server to register each one", "req.params is read-only and can't be set"],
    "answer": 1,
    "explain": "There is no :id placeholder. You write an anchored regex like /^\\/messages\\/(\\d+)$/ and read match[1] yourself - fiddly and error-prone, which is exactly why routers exist."
  },
  {
    "q": "A client sends DELETE /messages, but you only support GET and POST on that path. What's the correct response?",
    "choices": ["404 Not Found", "405 Method Not Allowed", "400 Bad Request", "500 Internal Server Error"],
    "answer": 1,
    "explain": "The path exists; only the method is wrong, so 405 Method Not Allowed (ideally with an Allow header) is correct. Returning 404 is a common hand-rolled mistake."
  }
]
```


---

# Middleware Is Just a Function

The word "middleware" gets thrown around like it's a framework feature you have to install and configure. It isn't. Here's the mental model that makes the whole thing click - hold it before you read a single line of code:

📝 **Middleware is a plain function you call before your handler.** That's the entire idea. In `node:http` there's no registration system, no `next()`, no internal chain - none of that machinery exists. There's just your `(req, res)` listener, and inside it you call some functions *before* the one that builds the response. A function that logs the request, checks for a token, or parses the body - those are "middleware." They earn the name only because of *where they run* (before the handler), not anything special about how they're written.

The simplest possible version: a function that takes `(req, res)`, does some cross-cutting work, and either responds (stopping the request) or returns quietly so your handler runs next. We're still building the running **messages** service, and the first thing every real server grows is a request logger - so let's start there.

## A logging "middleware," wired before the router

Every server you'll ever run needs to know what it's serving. The classic first middleware logs each request: method, URL, status code, and how long it took. Here it is, sitting in front of the router we built in [Routing by Hand](03-routing-by-hand.md):

```javascript
const http = require('node:http');

function logger(req, res) {
  const start = Date.now();
  res.on('finish', () => {
    console.log(`${req.method} ${req.url} ${res.statusCode} ${Date.now() - start}ms`);
  });
}

const server = http.createServer((req, res) => {
  logger(req, res);   // run "middleware" first
  route(req, res);    // then dispatch (Phase 3)
});

server.listen(3000, () => console.log('http://localhost:3000'));
```

*What just happened:* `logger` is nothing but a function. We record `start`, then attach a one-time listener to the response's `'finish'` event - fires when Node has flushed the full response - so we can read the *real* status code and elapsed time after the handler has done its work. `logger` doesn't wait around: it returns immediately, and `route(req, res)` runs right after. The logging happens later, as a side effect, when `finish` fires. Inside `createServer`, "run middleware first, then route" is literally two function calls in order - that ordering *is* the middleware pattern, no machinery underneath. Hit `GET /messages` and you'll see a line like `GET /messages 200 2ms`.

## A chain, and the art of short-circuiting

One middleware is a function call. A *chain* of middleware is several function calls in order - and the interesting part is that any one of them can **stop the request** before it reaches your handler. The way it stops is exactly what you'd guess from Phase 2: it writes a response and returns, and the caller has to know not to keep going.

The textbook case is authentication. A request with no credentials should never reach the handler - it should get a `401` and stop. Here's an auth check written as a middleware function:

```javascript
function sendJson(res, status, body) {
  res.writeHead(status, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(body));
}

function requireAuth(req, res) {
  if (!req.headers['authorization']) {
    sendJson(res, 401, { error: 'Unauthorized' });
    return false;   // tell the caller: I responded, do NOT continue
  }
  req.user = { id: 1, name: 'Ada' };   // attach data for later functions
  return true;      // authenticated - caller may continue
}
```

*What just happened:* `requireAuth` does the two things every gatekeeping middleware does. If there's no `authorization` header, it sends a `401` and returns `false` - that return value communicates "I already handled this request, stop." If the header *is* present, it attaches a `req.user` object (in real life you'd verify the token first) and returns `true`. Because there's no framework calling these functions for you, **you** are responsible for checking the return value and deciding whether to keep going. Wire it into the chain like this:

```javascript
const server = http.createServer((req, res) => {
  logger(req, res);
  if (!requireAuth(req, res)) return;   // short-circuit: stop the chain
  route(req, res);                      // reached only when authed
});
```

*What just happened:* this is a three-link chain - logger, auth, router - and `if (!requireAuth(...)) return;` is the short-circuit. When `requireAuth` responds with `401` and returns `false`, the `return` stops the listener cold and `route` never runs. ⚠️ That `return` is load-bearing: without it, the code would send the `401` *and* fall through to `route`, which would try to write a second response onto an already-ended connection - Node throws `ERR_HTTP_HEADERS_SENT`. Respond-and-return is the whole discipline; forget the `return` and you get the most common bug in hand-rolled servers.

Notice how data flows forward: `requireAuth` set `req.user`, so any handler downstream can read it. 💡 **Passing data down the chain means attaching it to `req`.** The request object is the shared scratchpad every function in the chain can see - an early middleware writes to it, a later handler reads from it. That's the same pattern every framework uses.

## This is exactly what Express formalizes

📝 If you've seen Express, the `(req, res, next)` signature and `app.use(...)` are doing *precisely* what you just wrote by hand - only Express maintains an internal list of these functions and calls `next()` for you to advance the chain, instead of you writing `if (!fn(...)) return;` between each call. Conceptually it's identical: functions that run around your handler, each able to respond-and-stop or enrich `req` and continue. Passing data down is still "attach it to `req`" (`req.user = ...`), exactly as here. When you're ready to see the same idea with the bookkeeping handled for you, that's [Express From Zero](/guides/express-from-zero) - it'll read as familiar, because you've already built the engine.

💡 Step back and look at what you've now got. You've hand-rolled a **logger**, an **auth check**, a **body parser** (back in [Handling Requests & Responses](02-requests-and-responses.md)), and a **router** ([Routing by Hand](03-routing-by-hand.md)). Stack those four together and you have, in miniature, exactly what Express *is*. That's not a coincidence - it's the entire point of this guide. A framework isn't magic; it's these same functions with the boilerplate factored out.

## Recap

- **Middleware in `node:http` is a plain function you call before your handler** - log, authenticate, parse. There is no special machinery; it's just function calls in order inside your `(req, res)` listener.
- A **chain** is several middleware called in sequence. Any one can **short-circuit** by responding and returning a signal (e.g. `false`) so the caller stops.
- The **respond-and-return** discipline is everything: forget the `return` after sending a response and you'll try to write twice, triggering `ERR_HTTP_HEADERS_SENT`.
- **Pass data down the chain by attaching it to `req`** (e.g. `req.user`). The request object is the shared scratchpad every later function can read.
- **Express formalizes this exact idea** with `(req, res, next)` and an internal chain that calls `next()` for you ([Express From Zero](/guides/express-from-zero)) - conceptually the same functions you just wrote.
- Logger + auth + body parser + router = a tiny Express. Building them by hand is the point.

## Quick check

```quiz
[
  {
    "q": "In node:http, what is 'middleware', really?",
    "choices": ["A built-in module you import", "A plain function you call before your handler", "A special config object passed to createServer", "A third-party package that registers itself automatically"],
    "answer": 1,
    "explain": "In node:http there's no machinery - middleware is just a function you call before your handler, to do cross-cutting work like logging or auth."
  },
  {
    "q": "An auth function sends a 401 and you forget to `return` before calling the router. What happens?",
    "choices": ["Nothing - Node ignores the second response", "The router runs and tries to write a second response, throwing ERR_HTTP_HEADERS_SENT", "The request silently hangs forever", "The 401 is overwritten with a 200"],
    "answer": 1,
    "explain": "Without the return, the listener falls through to the router, which writes onto an already-ended response - Node throws ERR_HTTP_HEADERS_SENT."
  },
  {
    "q": "How does an early middleware pass data (like the authenticated user) to a later handler?",
    "choices": ["By returning it from createServer", "By writing it to a global variable", "By attaching it to req (e.g. req.user = ...)", "By passing it as a third argument Node provides"],
    "answer": 2,
    "explain": "The req object is the shared scratchpad: an early function attaches data to req, and any later function in the chain can read it. Express works the same way."
  }
]
```


---

# A JSON REST API With No Framework

This is the payoff phase. Everything you built in the last three chapters - reading and writing JSON ([Phase 2](02-requests-and-responses.md)), dispatching by method and path ([Phase 3](03-routing-by-hand.md)), wrapping handlers in a logger ([Phase 4](04-middleware-is-a-function.md)) - has been one piece of the same machine. Now we bolt them together into a real, working REST API. No Express, no Fastify, no npm install - just `node:http` and the helpers you already wrote.

Here's the mental model to carry through this whole phase: **a REST resource is five operations, dispatched by method plus path.** That's the entire shape of CRUD.

| Operation | Method + path | Success status |
|-----------|---------------|----------------|
| List all | `GET /messages` | 200 OK |
| Read one | `GET /messages/:id` | 200 OK (or 404) |
| Create | `POST /messages` | 201 Created (or 400) |
| Update | `PUT /messages/:id` | 200 OK (or 404) |
| Delete | `DELETE /messages/:id` | 204 No Content (or 404) |

This is the *same five-operation shape* every framework hands you. The difference is that here you can see all of it - there's no magic layer doing the wiring. We're building the thing `app.get(...)` wraps. Once you've built it once by hand, the framework stops being mysterious and starts being a convenience you understand.

We're finishing the **messages** service that's run through the whole guide. Each message is `{ id, text }`.

## The server and the store

First, the foundation: one `http.createServer`, an in-memory store, and the helpers from earlier phases.

```javascript
import http from 'node:http';

// --- helpers from Phase 2 ---
function readJson(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', chunk => { body += chunk; });
    req.on('end', () => {
      try { resolve(body ? JSON.parse(body) : {}); }
      catch (e) { reject(e); }
    });
    req.on('error', reject);
  });
}

function sendJson(res, status, data) {
  res.writeHead(status, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data));
}

// --- the in-memory store ---
let messages = [];   // each item: { id, text }
let nextId = 1;
```

*What just happened:* we pulled in `readJson` and `sendJson` exactly as we wrote them in Phase 2 - no changes needed, the whole point of building them as standalone helpers. The store is two plain variables: an array of messages and a counter for the next id. No database, no ORM, nothing to install.

📝 Notice there are **no locks** anywhere, and that's correct, not lazy. Node runs your JavaScript on a single thread, so two requests never mutate `messages` at literally the same instant - one handler runs to its next `await` before another gets a turn. A multi-threaded server (Java, Go) needs synchronization around shared state; here you don't. (The flip side: this data vanishes when the process restarts. A real app swaps these two variables for a database - the only part that changes.)

## The five handlers

Each handler does one operation. They all read from `req` and answer with `sendJson` (or, for delete, a bare 204). Read them as a set - the symmetry is the lesson.

```javascript
// GET /messages - list all
function listMessages(req, res) {
  sendJson(res, 200, messages);
}

// GET /messages/:id - read one
function getMessage(req, res, id) {
  const msg = messages.find(m => m.id === id);
  if (!msg) return sendJson(res, 404, { error: 'Message not found' });
  sendJson(res, 200, msg);
}

// POST /messages - create
async function createMessage(req, res) {
  const body = await readJson(req);

  if (typeof body.text !== 'string' || body.text.trim() === '') {
    return sendJson(res, 400, { error: 'Field "text" is required and must be a non-empty string' });
  }

  const msg = { id: nextId++, text: body.text };
  messages.push(msg);
  sendJson(res, 201, msg);
}

// PUT /messages/:id - update
async function updateMessage(req, res, id) {
  const msg = messages.find(m => m.id === id);
  if (!msg) return sendJson(res, 404, { error: 'Message not found' });

  const body = await readJson(req);
  if (typeof body.text !== 'string' || body.text.trim() === '') {
    return sendJson(res, 400, { error: 'Field "text" is required and must be a non-empty string' });
  }

  msg.text = body.text;
  sendJson(res, 200, msg);
}

// DELETE /messages/:id - delete
function deleteMessage(req, res, id) {
  const index = messages.findIndex(m => m.id === id);
  if (index === -1) return sendJson(res, 404, { error: 'Message not found' });

  messages.splice(index, 1);
  res.writeHead(204);
  res.end();           // 204 = no body, by definition
}
```

*What just happened:* five operations, each mapping a status code to an outcome. `listMessages` always returns the array with 200. `getMessage` looks up by id and returns the message, or 404 if there's no match. `createMessage` and `updateMessage` both `await readJson(req)` then **validate `text` by hand** - if it's missing, not a string, or blank, they bail out with a 400 and never touch the store. `createMessage` mints a fresh id and answers 201 Created; `deleteMessage` removes the item and answers 204 with `res.end()` and no body. Every "not found" path `return`s early, so we never accidentally respond twice.

⚠️ That validation is doing real work - **never trust input.** A framework would give you a `body-parser` plus a schema validator; here, *you* are the validator. Check the shape before acting on it, and reject bad input with a 400 that says what was wrong. Without these guards, a client sending `{}` would create a message with `text: undefined`, and your "list" endpoint would start serving garbage.

## Wiring it together: dispatch

Now the part that ties the handlers to the wire. One `createServer` listener runs the logger from Phase 4, parses the path, and dispatches by method plus path - including the regex match for `/:id` from Phase 3 - all inside a `try/catch`.

```javascript
function log(req) {
  console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
}

const server = http.createServer(async (req, res) => {
  log(req);   // middleware from Phase 4 - runs before any handler

  try {
    const url = new URL(req.url, 'http://localhost');
    const path = url.pathname;
    const idMatch = path.match(/^\/messages\/(\d+)$/);   // capture the :id

    // collection routes
    if (req.method === 'GET'  && path === '/messages') return listMessages(req, res);
    if (req.method === 'POST' && path === '/messages') return createMessage(req, res);

    // item routes (/messages/:id)
    if (idMatch) {
      const id = Number(idMatch[1]);
      if (req.method === 'GET')    return getMessage(req, res, id);
      if (req.method === 'PUT')    return updateMessage(req, res, id);
      if (req.method === 'DELETE') return deleteMessage(req, res, id);
    }

    sendJson(res, 404, { error: 'Not Found' });   // nothing matched
  } catch (err) {
    console.error(err);
    sendJson(res, 500, { error: 'Internal Server Error' });
  }
});

server.listen(3000, () => console.log('messages API on http://localhost:3000'));
```

*What just happened:* the listener is the conductor. It logs first (the entire "middleware" idea from Phase 4 - a function you call before the handler), parses the URL once, and runs the regex once to capture any `:id`. Then it dispatches: collection routes (`/messages`) by method, item routes (`/messages/:id`) by method, and a 404 fallthrough for anything else. Each match `return`s so dispatch stops at the first hit.

⚠️ The whole dispatch sits inside a `try/catch` for a reason: if any handler throws - a bug, unexpected input, `readJson` rejecting on malformed JSON - the `catch` turns it into a clean **500 Internal Server Error** instead of crashing the process or leaving the client hanging. This is your last line of defense (sturdier and more structured in [Phase 6](06-async-streams-structure.md), but even this minimal version is non-negotiable).

## Driving it with curl

Start the server (`node server.mjs`) and exercise all five operations. Here's a full session, including the failure cases - those matter as much as the happy path.

```bash
# Create one (201)
$ curl -s -X POST localhost:3000/messages -d '{"text":"hello"}'
{"id":1,"text":"hello"}

# Create another (201)
$ curl -s -X POST localhost:3000/messages -d '{"text":"world"}'
{"id":2,"text":"world"}

# List all (200)
$ curl -s localhost:3000/messages
[{"id":1,"text":"hello"},{"id":2,"text":"world"}]

# Read one (200)
$ curl -s localhost:3000/messages/1
{"id":1,"text":"hello"}

# Update (200)
$ curl -s -X PUT localhost:3000/messages/1 -d '{"text":"hi there"}'
{"id":1,"text":"hi there"}

# Delete (204 - no body comes back)
$ curl -s -i -X DELETE localhost:3000/messages/2 | head -1
HTTP/1.1 204 No Content

# --- the failure cases ---

# Missing text → 400
$ curl -s -X POST localhost:3000/messages -d '{}'
{"error":"Field \"text\" is required and must be a non-empty string"}

# No such id → 404
$ curl -s localhost:3000/messages/999
{"error":"Message not found"}
```

*What just happened:* every row is one of the five operations answering with the right status and body. The two failure cases are the important ones to internalize: a `POST` with no `text` gets a **400** and never enters the store, a `GET` for an id that doesn't exist gets a **404** - the guards from your handlers firing exactly as designed. A 204 delete returns no body at all (`-i` shows the status line - there's nothing else to show).

## You built a complete API - now count the cost

Step back and look at what this is. A fully working REST API: five CRUD operations, JSON in and out, path parameters, input validation, correct status codes (200/201/204/400/404/500), request logging, and a crash-proof error boundary. **Zero dependencies.** Your `node_modules` folder doesn't exist. You could ship this.

💡 But now count the boilerplate. To get those five routes you hand-wrote: URL parsing, a regex for `:id`, capture-group extraction, a method-and-path `if`-ladder, two near-identical validation blocks, the 404 fallthrough, and the `try/catch`. In [Express (Phase 7)](07-what-express-adds.md) the same API is `app.get`, `app.post`, `app.put`, `app.delete`, `express.json()`, and `req.params.id` - routing, body parsing, and param extraction all collapse into declarations. **That delta - everything you wrote here that Express writes for you - is precisely the value a framework adds.** You're not learning Express to skip understanding this; you're learning it *because* you now understand exactly what it's doing on your behalf, and can tell when you don't need it.

## Recap

- A REST resource is **five operations dispatched by method plus path** - list, read-one, create, update, delete - and that shape is identical to what every framework gives you.
- The store is a plain `let messages = []` and a `nextId` counter; **Node's single thread means no locks** on shared state, but the data is in-memory and vanishes on restart (a real app uses a DB).
- One `createServer` listener runs the logger, parses the URL, dispatches by method/path (regex for `/:id`), and answers with `sendJson` - reusing the Phase 2–4 pieces unchanged.
- **Validate every input by hand** - reject missing or non-string `text` with a 400 before touching the store; never trust the client.
- Wrap the whole dispatch in a `try/catch` so any thrown error becomes a clean **500** instead of a crash (deepened in [Phase 6](06-async-streams-structure.md)).
- This is a complete, dependency-free API - and the boilerplate it took is exactly what a framework removes.

## Quick check

```quiz
[
  {
    "q": "Why does this in-memory messages store need no locks around `messages.push(...)`?",
    "choices": ["Arrays in JavaScript are immutable", "Node runs your JS on a single thread, so two handlers never mutate it at the same instant", "node:http serializes every request through a queue you configure", "The `let` keyword makes the variable thread-safe"],
    "answer": 1,
    "explain": "Node executes your JavaScript on one thread. A handler runs until its next await before another gets a turn, so shared in-memory state is never touched concurrently - unlike a multi-threaded server."
  },
  {
    "q": "A client sends POST /messages with body {} (no text field). What should the handler do?",
    "choices": ["Create a message with text: undefined and return 201", "Return 400 Bad Request and not touch the store", "Return 404 Not Found", "Return 204 No Content"],
    "answer": 1,
    "explain": "You validate input by hand: if `text` is missing or not a non-empty string, respond 400 and never add to the store. Trusting the client would serve garbage from your list endpoint."
  },
  {
    "q": "Why is the whole dispatch wrapped in a try/catch?",
    "choices": ["To make the handlers run faster", "So a thrown error becomes a clean 500 instead of crashing the process or hanging the client", "Because await can only be used inside try/catch", "To automatically retry failed requests"],
    "answer": 1,
    "explain": "Any handler can throw - a bug, bad input, a rejected readJson. The catch converts that into a 500 Internal Server Error response, keeping the server alive and the client informed."
  }
]
```


---

# Async, Streams & Structure

You've got a working messages API now ([A JSON REST API With No Framework](05-rest-api-no-framework.md)). It routes, it parses bodies, it does CRUD. But it's the demo version. The thing you'd actually deploy is shaped differently in four ways - hold all four at once before we touch code:

📝 **Real servers are async, they stream data instead of buffering it, they fail gracefully, and they're split into modules.** That's the whole phase. Your handlers will `await` things (a database, a file read), so an error inside them has to be caught or the request hangs. Big responses get *piped* through `res` instead of loaded into memory. A deploy or restart needs to drain in-flight requests instead of severing them. And the one-file server splits into `store.js`, `router.js`, `handlers.js`, `http-helpers.js`, and `server.js`. None of these are framework features - they're the same `node:http` you already know, grown up.

## Async handlers, and the error that hangs

The moment your handler does real work - reads from a database, calls another service, awaits `readJson` - it becomes `async`, and async handlers have a trap that bites everyone exactly once.

⚠️ **An unhandled rejection in an async handler does NOT auto-respond.** There's no framework standing behind your function to catch the throw and send a `500`. If an `await` rejects and nothing catches it, the request never gets a response - it hangs until the client times out, or the rejection takes the whole process down. So you wrap your dispatch in one `try/catch` at the top, and that single catch becomes the safety net for every handler underneath it:

```javascript
const http = require('node:http');

const server = http.createServer(async (req, res) => {
  try {
    await route(req, res);
  } catch (err) {
    console.error(err);
    if (!res.headersSent) sendJson(res, 500, { error: 'Internal Server Error' });
  }
});
```

*What just happened:* the listener is now `async`, and `await route(req, res)` means any handler the router calls can reject and the rejection will surface right here in the `catch`. We log the real error server-side (the stack trace belongs in your logs, not the client's response) and send a generic `500`. The `if (!res.headersSent)` guard is the load-bearing part: `headersSent` is a boolean Node flips to `true` the instant `res.writeHead` runs. If the handler already started writing a response and *then* threw partway through, the headers are already on the wire - trying to send a second response would throw `ERR_HTTP_HEADERS_SENT` (the same double-write bug from [Middleware Is Just a Function](04-middleware-is-a-function.md)). One `try/catch` at the dispatch boundary covers every async handler in the app.

💡 This is the event loop biting you. An async handler that rejects without a catch is just an unhandled promise rejection, and Node's default for those is increasingly hostile (it can crash the process). If the mechanics feel fuzzy, [Async/Await and the Event Loop](/guides/async-await-and-the-event-loop) is the prerequisite - this phase assumes you've got that model.

## Streaming: `res` is a writable stream

Here's the `node:http` superpower most people never reach for. Up to now you've built responses in memory - `JSON.stringify(body)`, then `res.end(string)`. That's fine for small JSON, a disaster for a large file, because you'd load the entire thing into memory before sending a single byte. A 2 GB file means 2 GB of RAM, per request.

📝 **`res` is a writable stream, and `req` is a readable one.** Not a metaphor - real Node stream objects. Which means you can take a readable stream (a file on disk) and *pipe* it straight into `res`, and Node moves the data through in small chunks. Constant memory, regardless of file size:

```javascript
const fs = require('node:fs');

function streamFile(req, res) {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  fs.createReadStream('big.json').pipe(res);
}
```

*What just happened:* `fs.createReadStream('big.json')` opens the file as a readable stream - it does *not* read the file into memory. `.pipe(res)` connects that readable to the writable response, and Node pumps the file through in chunks, handling backpressure for you (if the client reads slowly, Node slows the file read to match). `pipe` also calls `res.end()` automatically when the file is exhausted, so you don't. The one thing you *must* do first is `writeHead` with the right `Content-Type` - once data starts flowing through the pipe, the headers are locked. A 2 GB file streamed this way uses kilobytes of memory, not gigabytes.

💡 And `req` being a readable stream isn't new - it's what you've been consuming since Phase 2. When you read a request body by listening for `'data'` and `'end'` events, you were draining the `req` readable stream chunk by chunk. Reading the body and piping a file are the same mechanism pointed in opposite directions: `req` flows *in*, `res` flows *out*.

## Graceful shutdown: let in-flight requests finish

Your server doesn't run forever. It gets deployed over, restarted, scaled down - and when that happens, the orchestrator (a container runtime, systemd, whatever) sends your process a signal: `SIGTERM`, usually, or `SIGINT` when you hit Ctrl-C locally. The default behavior is brutal: the process dies immediately, and any request that was mid-response gets its connection severed. The client sees a dropped connection, a half-written database transaction, a truncated file download.

⚠️ **Without graceful shutdown, every deploy kills your in-flight requests.** On a busy server, a restart means a burst of errors for whoever happened to be mid-request. The fix is small - capture the server object and handle the signal:

```javascript
const server = http.createServer(/* ... */);
server.listen(3000, () => console.log('http://localhost:3000'));

process.on('SIGTERM', () => {
  server.close(() => {
    console.log('drained, exiting');
    process.exit(0);
  });
});
```

*What just happened:* `process.on('SIGTERM', ...)` registers a handler for the terminate signal. Inside it, `server.close(callback)` does two things: stops accepting *new* connections immediately, and waits for all *in-flight* requests to finish before calling your callback. New traffic gets refused (the load balancer routes it elsewhere), the requests already being handled run to completion, and only then - once fully drained - do we log and exit cleanly with code `0`. Add a matching `process.on('SIGINT', ...)` if you want Ctrl-C to drain too. In production you'd also set a timeout so a stuck request can't block shutdown forever, but `server.close` is the core of it.

💡 This is the piece that turns a hobby server into something you can actually deploy - graceful shutdown is what keeps rolling restarts invisible to users. See [Ship Your Side Project](/guides/ship-your-side-project) for where this fits in the deploy story.

## Structure: one file becomes five

Everything so far has lived in a single growing file, and it's gotten crowded - `createServer`, the router, every handler, the JSON helpers, the in-memory store, all stacked together. Correct for *learning* (you can see the whole machine on one screen), wrong for *maintaining*. As it grows, split it along the seams already there:

- **`store.js`** - the data layer. The messages array and the functions that touch it (`getAll`, `getById`, `create`, `remove`). Nothing here knows about HTTP.
- **`http-helpers.js`** - `readJson(req)` and `sendJson(res, status, body)`. The reusable request/response plumbing.
- **`handlers.js`** - the operations. `listMessages`, `createMessage`, etc. - each reads the request, calls the store, sends a response.
- **`router.js`** - the dispatch. Matches method + path and calls the right handler (the `route` function from Phase 3).
- **`server.js`** - wires it together: `createServer` with the `try/catch`, then `listen`, plus the shutdown handler.

📝 The one split that matters most: **separate creating the server from calling `listen` on it.** Have `server.js` (or a small `app.js`) build and *export* the server object, and let the entry point be the only thing that calls `.listen(3000)`. Why? Because now a test can `require` your server, fire requests at it without ever binding to a port, and assert on the responses - no live socket, no port conflicts in CI. This server-as-a-value pattern is exactly the testability discipline Express formalizes when you `module.exports = app`.

These five files are still pure `node:http`. Nothing changed about how the server *works* - you just drew lines between the data, the plumbing, the operations, the routing, and the wiring, so each piece can be read, tested, and changed on its own.

## Recap

- **Handlers become `async`** the moment they do real work, and an unhandled rejection inside one will NOT auto-respond - the request hangs or the process crashes. Wrap dispatch in one top-level `try/catch` and send a `500`.
- **Guard the 500 with `if (!res.headersSent)`** - if a handler already started writing before it threw, a second response triggers `ERR_HTTP_HEADERS_SENT`.
- **`res` is a writable stream and `req` is a readable one.** Pipe a file straight into the response (`fs.createReadStream(...).pipe(res)`) for constant memory regardless of size. Set `Content-Type` first. Reading a body is just consuming the `req` stream.
- **Graceful shutdown** means handling `SIGTERM`/`SIGINT` with `server.close(cb)`: stop accepting new connections, let in-flight ones finish, then exit. Without it, every deploy severs live requests.
- **Split the one file into modules** - `store`, `http-helpers`, `handlers`, `router`, `server` - along the seams already in the code.
- **Separate building the server from calling `listen`** so tests can drive it without binding a port - the same testability idea Express formalizes.

## Quick check

```quiz
[
  {
    "q": "An async handler awaits a database call that rejects, and nothing catches it. With no try/catch around dispatch, what happens?",
    "choices": ["Node automatically sends a 500 response", "The request gets no response - it hangs, and the unhandled rejection can crash the process", "The handler is retried automatically", "The client receives the raw error and stack trace"],
    "answer": 1,
    "explain": "There's no framework behind your handler to catch the throw. An unhandled rejection means the request never gets a response and Node may crash the process - so you wrap dispatch in a try/catch and send a 500 yourself."
  },
  {
    "q": "Why pipe a large file with fs.createReadStream('big.json').pipe(res) instead of reading it and calling res.end()?",
    "choices": ["pipe is faster to type", "Streaming sends the file in chunks, using constant memory regardless of file size, instead of loading the whole file into RAM", "res.end can't send files", "pipe sets the Content-Type automatically"],
    "answer": 1,
    "explain": "res is a writable stream, so piping a readable file into it moves data in small chunks with constant memory. Reading the whole file first would load all of it into RAM - fatal for large files."
  },
  {
    "q": "What does server.close(callback) do when called on SIGTERM?",
    "choices": ["Kills all connections instantly, then runs the callback", "Stops accepting new connections, waits for in-flight requests to finish, then runs the callback", "Closes only idle connections and leaves the server listening", "Restarts the server on a new port"],
    "answer": 1,
    "explain": "server.close stops accepting new connections immediately but lets in-flight requests complete, calling the callback once the server has fully drained - so a deploy doesn't sever live requests."
  }
]
```


---

# What Express Adds

Stop and look at what you pulled off. You built a real JSON REST API - a server, a router that switches
on method and path, body parsing that drains a stream into an object, middleware as plain functions
running before your handler, async handlers, streaming, a try/catch that turns thrown errors into a 500,
and a server that shuts down without dropping in-flight requests. And you did it with one import:
`node:http`. No framework docs, no magic - just `createServer` and the `(req, res)` function you hand it.

That was the whole point. The frameworks people reach for - [Express](/guides/express-from-zero),
[Fastify](/guides/fastify-from-zero) - are not a different universe. They're conveniences stacked on the
exact skeleton you now hold in your hands. So this last phase is the payoff: we point your new X-ray
vision at Express and watch the "magic" turn back into machinery you can already name.

## Mapping the magic back to the mechanism

💡 Here's the line worth reading slowly: every "feature" Express advertises is a convenience over
something you wrote by hand in this guide. Once you've seen the bare version, `app.get(...)` stops being
a spell.

```mermaid
flowchart LR
  R["app.get('/x/:id')"] --> A["your if-ladder + regex params"]
  B["express.json()"] --> C["your readJson stream-reader"]
  M["(req, res, next) chain"] --> D["your call-a-fn-first wrapper"]
  H["res.json() / res.status()"] --> E["your sendJson helper"]
  X["4-arg error middleware"] --> F["your try/catch to 500"]
```

Reading that left to right, in plain words:

- **Routing.** Your method-plus-path `if`-ladder and hand-rolled regex for `:id` params (Phase 3) become
  `app.get('/messages/:id', handler)` - Express matches the method, matches the path, parses the param,
  and hands you `req.params.id`. Same job you did by hand, done for you.
- **Body parsing.** Your `readJson` that listened for `data` chunks and `end`, then `JSON.parse`d the
  buffer (Phase 2), becomes one line: `app.use(express.json())`. After that, `req.body` is already the
  parsed object by the time your handler runs.
- **Middleware.** Your "call a function before the handler" idea (Phase 4) becomes the formal
  `(req, res, next)` chain. You call `next()` to pass control along; Express runs the chain for you,
  in order, and lets any link short-circuit by sending a response instead of calling `next()`.
- **Response helpers.** Your `sendJson` that set the status, set `Content-Type`, and wrote
  `JSON.stringify(...)` (Phase 2) becomes `res.status(201).json(obj)`. Same three steps, one fluent call.
- **Error handling.** Your try/catch that caught a thrown error and wrote a 500 (Phase 6) becomes a
  special **4-argument** middleware, `(err, req, res, next)`, that Express routes errors to. And in
  **Express 5**, errors thrown from `async` handlers are auto-forwarded to it - no more wrapping every
  handler in try/catch.

That covers Express. 💡 So "Express" really is the sum of the small conveniences you already wrote,
plus one thing you couldn't build in a weekend: a huge **middleware ecosystem** - `cors`, `helmet`,
session and auth packages, rate limiters, and on and on. [Fastify](/guides/fastify-from-zero) sits on the
same `node:http` foundation and adds two things of its own on top: **schema-based validation** and a focus
on **speed**. Different ergonomics, identical skeleton underneath.

## Do I even need a framework?

📝 Let me be direct, because a roots guide that pretends you must reach for a framework would be lying to
you: a lot of the time, plain `node:http` is genuinely fine.

For a tiny service, a one-off script, or learning, the standard library is a real answer - zero
dependencies, full control, nothing to upgrade or audit, and you understand every line because you wrote
it. You shipped a working CRUD API without installing anything - not a toy; for a small surface it's a
legitimate choice.

So when do you reach for a framework? When the job grows:

- **Many routes, real middleware, a team, a long-lived app** - reach for **[Express](/guides/express-from-zero)**.
  Once you have dozens of endpoints, the routing, body parsing, and error plumbing you hand-rolled turn
  into boilerplate you'd be rewriting in every file. A framework removes that boilerplate (and the bugs
  that hide in it) and hands you an ecosystem for the things you don't want to write yourself, like CORS,
  security headers, and auth.
- **You want validation and raw throughput** - look at **[Fastify](/guides/fastify-from-zero)**, which
  adds JSON-schema validation and serialization speed on the same foundation.

That's the plain decision. Not "always use a framework," and not "frameworks are bloat" - match the tool
to the size of the job. (The Go world has the exact same conversation; see the parallel roots guide,
[Web Services With Only net/http](/guides/web-services-with-only-net-http).)

## Where to go from here

You're now in the rare, comfortable spot of being able to pick a framework with your eyes open instead of
cargo-culting a tutorial. Two small moves will lock it in:

1. **Re-read your Express phase with these mappings in hand.** Go back through
   [Express From Zero](/guides/express-from-zero) and, for each feature, name the bare version you built
   here. `app.get` → your if-ladder. `express.json()` → your `readJson`. `next` → your call-before-handler.
   `res.json` → your `sendJson`. The framework will read as `node:http` with the boilerplate filed off.
2. **Build one small thing twice.** Write a two-route service with plain `node:http`, then write the
   same service with Express. Feeling the delta yourself - what got shorter, what got safer - teaches
   more than any comparison table.

## The skeleton was always one function

Here's the line to carry out of this whole guide: `http.createServer` calls your `(req, res)` function
once per request, and everything else - routing, middleware, parsing, responses - is code. You wrote
that code. Express and Fastify write it for you and add an ecosystem, but the bones are identical.

You didn't learn one framework. You learned the foundation under *all* of them. Open the source of any
Node web codebase now - an Express app, a Fastify service, or some no-framework server at your job - and
you'll find the same `(req, res)` skeleton you built by hand. You can read all of it.

## Recap

1. **Express is conveniences over node:http, not a separate world.** Routing, body parsing, middleware,
   response helpers, and error handling each map to something you wrote in this guide.
2. **The mappings:** `app.get('/x/:id')` is your if-ladder plus regex params; `express.json()` is your
   `readJson`; the `(req, res, next)` chain is your call-a-function-first idea; `res.status().json()` is
   your `sendJson`; the 4-arg error middleware is your try/catch-to-500 (and Express 5 auto-forwards async errors).
3. **What you can't build in a weekend is the ecosystem** - `cors`, `helmet`, auth, rate limiting - which
   is the real reason to reach for Express. Fastify adds schema validation and speed on the same foundation.
4. **For a tiny service, a script, or learning, plain node:http is genuinely fine** - zero deps, full
   control. For real apps with many routes, middleware, and a team, a framework removes boilerplate and bugs.
5. **The skeleton is always `http.createServer` calling your `(req, res)` function** - learn it once and
   you can read any Node web codebase, framework or not.

## Quick check

One last check - the mappings that turn Express from magic into mechanism:

```quiz
[
  {
    "q": "In Express, what does express.json() correspond to in the bare node:http server you built?",
    "choices": [
      "Your readJson helper that drained the request stream and JSON.parsed the body",
      "Your method-and-path if-ladder router",
      "Your sendJson response helper",
      "The http.createServer call itself"
    ],
    "answer": 0,
    "explain": "express.json() is body-parsing middleware: it does the stream-draining and JSON.parse you hand-wrote in Phase 2, then puts the result on req.body before your handler runs."
  },
  {
    "q": "What is Express middleware, mechanically, relative to what you wrote in Phase 4?",
    "choices": [
      "The same call-a-function-before-the-handler idea, formalized as a (req, res, next) chain Express runs for you",
      "A second http.Server running alongside the first",
      "A browser-side library with no server role",
      "A database connection pool"
    ],
    "answer": 0,
    "explain": "Your Phase 4 'call a function before the handler' is exactly Express middleware. Express formalizes it as (req, res, next): you call next() to continue, or send a response to short-circuit the chain."
  },
  {
    "q": "When is reaching for Express most justified over plain node:http?",
    "choices": [
      "When you have many routes, real middleware, a team, and want an ecosystem like cors/helmet/auth",
      "Whenever you need to respond with JSON at all, since node:http can't do it",
      "Only when you cannot use the standard library for licensing reasons",
      "Never - node:http and Express are identical in every way"
    ],
    "answer": 0,
    "explain": "For a tiny service or learning, plain node:http is fine. Express earns its keep on real apps: it removes routing/parsing/error boilerplate and gives you a huge middleware ecosystem. Fastify adds schema validation and speed on the same foundation."
  }
]
```
