HTTP all 3 written
A web server from nothing, a JSON API with no framework, and fetch -- every request on these pages really sent, to a server the panel started itself.
36 An HTTP server from nothing
A web server is a program that waits for connections and answers each
request that arrives on one. Node has one built in: node:http. Frameworks such as
Express and Fastify are built on exactly this module, so seeing it bare is the best way to understand
what they do for you.
A server, and a request to it
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
// This function runs once for every request that arrives.
const server = createServer((req, res) => {
res.setHeader("content-type", "text/plain; charset=utf-8");
res.end("Hello from Node");
});
server.listen(0, "127.0.0.1"); // port 0: any free port
await once(server, "listening");
const { port } = server.address();
console.log("listening on port", port);
// Be the browser for a moment.
const response = await fetch(`http://127.0.0.1:${port}/`);
const body = await response.text();
console.log(response.status, response.statusText);
console.log(body);
server.close();
check("the server answered 200 OK", response.status, 200);
check("with the body it wrote", body, "Hello from Node");
What node printed, when the build ran it
node example.mjs
listening on port 53014 200 OK Hello from Node ok the server answered 200 OK ok with the body it wrote
createServer takes a function, the request handler, and Node calls it with
two objects for every request: req, what the client asked for, and res, the
response you build. res.end() sends it. listen opens the port.
Two choices in that listen call are worth knowing. "127.0.0.1" is the
loopback address: only programs on this machine can connect, which is what you want while
developing. Port 0 asks the operating system for any free port, which is what every
panel on these pages uses so that none of them can collide; in your own code you would pick a fixed
one, such as 3000, so you know where to point the browser. fetch here is just playing the
browser; lesson 38 is about it.
What actually goes over the wire
HTTP is text. A request is a few lines sent over a network connection, a response is a few lines
sent back, and fetch and browsers hide that. The next panel skips them and writes the
request by hand over a raw TCP socket, from node:net.
example.mjs
import { createServer } from "node:http";
import { connect } from "node:net";
import { once } from "node:events";
const server = createServer((req, res) => {
res.setHeader("content-type", "text/plain");
res.end("hello");
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
// An HTTP request, typed out: a request line, headers, then a blank line.
const request = "GET /hello HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
const socket = connect(server.address().port, "127.0.0.1");
socket.write(request);
let raw = "";
for await (const chunk of socket) raw += chunk;
server.close();
console.log("--- sent ---");
for (const line of request.split("\r\n").slice(0, -1)) console.log(line || "(blank line)");
console.log("--- received ---");
for (const line of raw.split("\r\n")) console.log(line || "(blank line)");
const lines = raw.split("\r\n");
check("the first line is the status", lines[0], "HTTP/1.1 200 OK");
check("Node worked out the length itself", lines.includes("Content-Length: 5"), true);
check("and the body comes after a blank line", lines.at(-1), "hello");
What node printed, when the build ran it
node example.mjs
--- sent --- GET /hello HTTP/1.1 Host: 127.0.0.1 Connection: close (blank line) --- received --- HTTP/1.1 200 OK content-type: text/plain Date: Wed, 30 Sep 2026 00:44:56 GMT Connection: close Content-Length: 5 (blank line) hello ok the first line is the status ok Node worked out the length itself ok and the body comes after a blank line
The request is a request line (method, path, protocol version), then headers, then a blank
line. The response is a status line, headers, a blank line, and the body. The handler only
set content-type; Node added Date, Connection and
Content-Length itself. That is the entire protocol a web server speaks, and everything
else in this part is detail on top of it.
Reading the request
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
let seen;
const server = createServer(async (req, res) => {
// req.url is only the path and query. URL needs a base to parse it.
const url = new URL(req.url, "http://localhost");
// req is a readable stream: the body arrives in chunks.
let body = "";
for await (const chunk of req) body += chunk;
seen = {
method: req.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams),
contentType: req.headers["content-type"],
body: JSON.parse(body),
};
res.end("thanks");
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
await fetch(`http://127.0.0.1:${server.address().port}/orders?express=true&qty=2`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: "tea" }),
});
server.close();
console.log("the server saw:", seen);
check("the method and the path", [seen.method, seen.path], ["POST", "/orders"]);
check("query values are always strings", seen.query, { express: "true", qty: "2" });
check("header names arrive in lower case", seen.contentType, "application/json");
check("and the body was read from the stream", seen.body, { item: "tea" });
What node printed, when the build ran it
node example.mjs
the server saw: {
method: 'POST',
path: '/orders',
query: { express: 'true', qty: '2' },
contentType: 'application/json',
body: { item: 'tea' }
}
ok the method and the path
ok query values are always strings
ok header names arrive in lower case
ok and the body was read from the stream
Everything the client sent is on req. req.url is only the path and query
string, so the standard URL class parses it — it needs a base to do that, and any base
will do. Query values are always strings: qty is "2", not 2, and it is your
job to convert and check it. Header names are lower-cased by Node, whatever case the client used.
The body is not on req at all, because it might be large. req is a
readable stream (lesson 34), and for await collects it chunk by chunk. That is also why
the handler is async.
Status codes and routes
example.mjs
import { createServer, STATUS_CODES } from "node:http";
import { once } from "node:events";
const server = createServer((req, res) => {
const { pathname } = new URL(req.url, "http://localhost");
if (pathname === "/") {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
return res.end("<h1>Home</h1>");
}
if (pathname === "/health") {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ ok: true }));
}
if (pathname === "/old-home") {
res.writeHead(301, { location: "/" });
return res.end();
}
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;
const results = [];
for (const path of ["/", "/health", "/old-home", "/nope"]) {
const r = await fetch(base + path, { redirect: "manual" });
const location = r.headers.get("location");
results.push({
path,
status: `${r.status} ${STATUS_CODES[r.status]}`,
header: location ? `location: ${location}` : r.headers.get("content-type"),
body: await r.text(),
});
}
server.close();
console.table(results);
check("each route got its status", results.map((r) => r.status.split(" ")[0]), ["200", "200", "301", "404"]);
What node printed, when the build ran it
node example.mjs
┌─────────┬─────────────┬─────────────────────────┬────────────────────────────┬─────────────────┐
│ (index) │ path │ status │ header │ body │
├─────────┼─────────────┼─────────────────────────┼────────────────────────────┼─────────────────┤
│ 0 │ '/' │ '200 OK' │ 'text/html; charset=utf-8' │ '<h1>Home</h1>' │
│ 1 │ '/health' │ '200 OK' │ 'application/json' │ '{"ok":true}' │
│ 2 │ '/old-home' │ '301 Moved Permanently' │ 'location: /' │ '' │
│ 3 │ '/nope' │ '404 Not Found' │ 'text/plain' │ 'not found' │
└─────────┴─────────────┴─────────────────────────┴────────────────────────────┴─────────────────┘
ok each route got its status
Routing is nothing more than looking at the path (and the method) and deciding what to send.
writeHead sets the status code and headers in one call. Every branch ends with
return res.end(...), and the next section shows why the return matters.
redirect: "manual" stops fetch following the 301, so you can see it.
| Code | Means | Send it when |
|---|---|---|
200 | OK | it worked, and here is the result |
201 | Created | a POST created something |
204 | No Content | it worked and there is nothing to send back, such as after a delete |
301 | Moved Permanently | the thing lives at the location header now |
400 | Bad Request | the client sent something invalid |
401 | Unauthorized | you do not know who they are: they need to log in |
403 | Forbidden | you know who they are, and they may not do this |
404 | Not Found | there is nothing at that path |
405 | Method Not Allowed | the path exists, but not for that method |
500 | Internal Server Error | your code failed; never the client's fault |
example.mjs
import { STATUS_CODES } from "node:http";
const codes = [200, 201, 204, 301, 400, 401, 403, 404, 405, 500];
for (const code of codes) console.log(code, STATUS_CODES[code]);
check("the table's names are Node's own", codes.map((c) => STATUS_CODES[c]), [
"OK", "Created", "No Content", "Moved Permanently", "Bad Request",
"Unauthorized", "Forbidden", "Not Found", "Method Not Allowed", "Internal Server Error",
]);
What node printed, when the build ran it
node example.mjs
200 OK 201 Created 204 No Content 301 Moved Permanently 400 Bad Request 401 Unauthorized 403 Forbidden 404 Not Found 405 Method Not Allowed 500 Internal Server Error ok the table's names are Node's own
The first digit is the part to remember: 2xx worked, 3xx look elsewhere, 4xx the client got something wrong, 5xx the server did.
Responding twice
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
const server = createServer((req, res) => {
if (!req.headers.authorization) {
res.writeHead(401, { "content-type": "text/plain" });
res.end("log in first\n");
}
res.writeHead(200, { "content-type": "text/plain" });
res.end("the secret\n");
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
await fetch(`http://127.0.0.1:${server.address().port}/`);
What node printed as it died, when the build ran it
node example.mjs
node:_http_server:414
throw new ERR_HTTP_HEADERS_SENT('write');
^
Error [ERR_HTTP_HEADERS_SENT]: Cannot write headers after they are sent to the client
at ServerResponse.writeHead (node:_http_server:414:11)
at Server.<anonymous> (file:///app/example.mjs:9:7)
at Server.emit (node:events:514:20)
at parserOnIncoming (node:_http_server:1394:12)
at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17) {
code: 'ERR_HTTP_HEADERS_SENT'
}
Node.js v26.7.0
The request had no authorization header, so the handler sent the 401 — and then,
with nothing to stop it, carried straight on and tried to send the 200 as well. The status line and
headers had already gone out, and they cannot be taken back. This is the most common bug in
hand-written handlers, and the fix is the habit from the router above: return
res.end(...), so nothing after a response ever runs.
One bad request, and the whole server is gone
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
const server = createServer((req, res) => {
const user = JSON.parse('{ "name": "Ada" }');
res.end(`hello ${user.profile.name}`); // there is no profile
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
await fetch(`http://127.0.0.1:${server.address().port}/`);
What node printed as it died, when the build ran it
node example.mjs
file:///app/example.mjs:6
res.end(`hello ${user.profile.name}`); // there is no profile
^
TypeError: Cannot read properties of undefined (reading 'name')
at Server.<anonymous> (file:///app/example.mjs:6:33)
at Server.emit (node:events:514:20)
at parserOnIncoming (node:_http_server:1394:12)
at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)
Node.js v26.7.0
An exception thrown in a handler is an uncaught exception, and an uncaught exception ends the process (lesson 18). For a server that means one bug in one request drops every other request in flight and refuses every one after it, until something restarts the process. So a server wraps every handler, turns an unexpected error into a 500, and keeps going:
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
// Wrap a handler so that any error, sync or async, becomes a 500.
function safely(handler) {
return async (req, res) => {
try {
await handler(req, res);
} catch (err) {
console.error("request failed:", req.method, req.url, "-", err.name);
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
res.end("something went wrong\n");
}
};
}
const server = createServer(safely(async (req, res) => {
if (req.url === "/broken") {
const user = JSON.parse('{ "name": "Ada" }');
return res.end(`hello ${user.profile.name}`);
}
res.end("fine\n");
}));
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;
const statuses = [];
for (const path of ["/", "/broken", "/"]) {
const r = await fetch(base + path);
statuses.push(r.status);
console.log(path, r.status, (await r.text()).trim());
}
server.close();
check("the broken request got a 500, and the server kept serving", statuses, [200, 500, 200]);
What node printed, when the build ran it
node example.mjs
/ 200 fine request failed: GET /broken - TypeError /broken 500 something went wrong / 200 fine ok the broken request got a 500, and the server kept serving
The client that hit the bug gets a 500 with a vague message — never the error itself, which can
reveal how your code works — and the details go to your own log. The request after it is served as
normal. The wrapper handles async handlers too: await inside the
try catches a rejected promise, which would otherwise be an unhandled rejection and end
the process just the same (lesson 28). Frameworks do exactly this for you.
A real server runs until it is stopped
Every panel so far closed its server so the page could finish. A real one does not. The panel
below starts node server.mjs as a separate process, exactly as you would in a terminal,
reads the address it prints, sends it three requests, and then has to stop it.
server.mjs
import { createServer } from "node:http";
let served = 0;
const server = createServer((req, res) => {
served++;
res.end(`request number ${served}\n`);
});
server.listen(0, "127.0.0.1", () => {
console.log(`listening on http://127.0.0.1:${server.address().port}`);
});
example.mjs
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { once } from "node:events";
import { setTimeout as sleep } from "node:timers/promises";
// node server.mjs, as its own process.
const server = spawn(process.execPath, ["server.mjs"], { stdio: ["ignore", "pipe", "inherit"] });
const [firstLine] = await once(createInterface({ input: server.stdout }), "line");
console.log("server.mjs printed:", firstLine);
const url = firstLine.replace("listening on ", "");
for (let i = 0; i < 3; i++) {
console.log(" " + (await (await fetch(url)).text()).trim());
}
await sleep(500);
const stillRunning = server.exitCode === null && server.signalCode === null;
console.log("half a second later, still running:", stillRunning);
server.kill("SIGTERM");
const [code, signal] = await once(server, "exit");
console.log("stopped by", signal);
check("a server never finishes on its own", stillRunning, true);
check("it ended when it was sent a signal", [code, signal], [null, "SIGTERM"]);
What node printed, when the build ran it
node example.mjs
server.mjs printed: listening on http://127.0.0.1:53030 request number 1 request number 2 request number 3 half a second later, still running: true stopped by SIGTERM ok a server never finishes on its own ok it ended when it was sent a signal
A listening server is something the event loop waits on, like the leftover timer that kept a
process alive in lesson 28 — except that this one never runs out, so the process never exits. At a
terminal you stop it with Ctrl+C, which sends
SIGINT; a process manager or container platform sends SIGTERM. Lesson 48
handles that signal so in-flight requests can finish first. The counter shows one more thing: the
process stays alive between requests, so a variable in it keeps its value from one to the next — the
third request saw 3. That is useful for a cache and dangerous for anything specific to one user.
Restarting while you work. node --watch server.mjs restarts the server
every time you save a file it uses. It is built in, and it replaces the nodemon package
most tutorials install.
createServer(handler)calls the handler withreqandresfor every request;listen(port, host)starts it.- HTTP is text: a request line or status line, headers, a blank line, a body.
- Parse
req.urlwithnew URL(req.url, base). Query values are strings. The body is a stream. writeHeadsets status and headers. Alwaysreturn res.end(...).- An exception in a handler ends the whole process. Catch per request and send a 500.
- A server runs until something stops it, and it keeps its variables between requests.
37 Routing, JSON and a REST API without a framework
Most servers today do not send pages. They send and receive JSON, for a browser app or a phone app or another server, and they are organised as an API: a set of paths, each answering a few methods. Frameworks make that shorter, but every one of them is doing three jobs — reading and writing JSON, matching paths to code, and turning errors into status codes. This lesson does all three by hand, then builds a small, complete API from them.
JSON in, JSON out, and what can go wrong
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
async function readJson(req, limit = 10_000) {
if (!req.headers["content-type"]?.startsWith("application/json")) {
throw new HttpError(415, "send JSON, with Content-Type: application/json");
}
let size = 0;
const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > limit) throw new HttpError(413, `the body is over ${limit} bytes`);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new HttpError(400, "the body is not valid JSON");
}
}
function sendJson(res, status, data) {
const text = JSON.stringify(data);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(text), // bytes, not characters
});
res.end(text);
}
const server = createServer(async (req, res) => {
try {
const order = await readJson(req);
sendJson(res, 200, { received: order });
} catch (err) {
if (err instanceof HttpError) return sendJson(res, err.status, { error: err.message });
console.error(err);
sendJson(res, 500, { error: "internal error" });
}
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const url = `http://127.0.0.1:${server.address().port}/orders`;
const attempts = [
["JSON", "application/json", '{ "item": "café", "qty": 2 }'],
["a trailing comma", "application/json", '{ "item": "tea", }'],
["plain text", "text/plain", "one tea please"],
["a megabyte", "application/json", JSON.stringify({ note: "x".repeat(1_000_000) })],
];
const statuses = [];
for (const [what, type, body] of attempts) {
const res = await fetch(url, { method: "POST", headers: { "content-type": type }, body });
statuses.push(res.status);
console.log(`${what.padEnd(17)} ${res.status} ${await res.text()}`);
}
server.close();
check("good JSON, bad JSON, not JSON, too much JSON", statuses, [200, 400, 415, 413]);
What node printed, when the build ran it
node example.mjs
JSON 200 {"received":{"item":"café","qty":2}}
a trailing comma 400 {"error":"the body is not valid JSON"}
plain text 415 {"error":"send JSON, with Content-Type: application/json"}
a megabyte 413 {"error":"the body is over 10000 bytes"}
ok good JSON, bad JSON, not JSON, too much JSON
Each way a body can be wrong gets its own status code, so the client can tell what to fix: 400 for
JSON that does not parse, 415 (Unsupported Media Type) for a body that was not declared as JSON at
all, and 413 (Payload Too Large) for one past the limit. The limit matters more than it looks.
Without one, readJson would keep every byte anyone chose to send in memory, and a few
large requests would be enough to take the server down; with one, reading stops as soon as the body
passes it.
The HttpError class is how a helper deep inside a request says "stop, and answer with
this status". The handler's catch tells the two kinds of error apart: an
HttpError is an expected problem, and its message is written for the client; anything
else is a bug, which is logged and answered with a bare 500. content-length is the length
in bytes, and "café" is why that is not the same as text.length
(lesson 30).
A router
Lesson 36 routed with a chain of ifs, which stops working as soon as a path has a
variable part like /todos/7. URLPattern, a standard class that is global in
Node, matches paths against patterns with named parts:
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
const routes = [];
function route(method, pathname, handler) {
routes.push({ method, pattern: new URLPattern({ pathname }), handler });
}
route("GET", "/todos", () => "all the todos");
route("GET", "/todos/:id", ({ id }) => `todo number ${id}`);
route("DELETE", "/todos/:id", ({ id }) => `deleted todo ${id}`);
const server = createServer((req, res) => {
const { pathname } = new URL(req.url, "http://localhost");
const matches = routes.filter((r) => r.pattern.test({ pathname }));
if (matches.length === 0) {
res.writeHead(404);
return res.end("no such path");
}
const match = matches.find((r) => r.method === req.method);
if (!match) {
res.writeHead(405, { allow: matches.map((r) => r.method).join(", ") });
return res.end("that path does not take that method");
}
const params = match.pattern.exec({ pathname }).pathname.groups;
res.end(match.handler(params));
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;
const seen = [];
for (const [method, path] of [["GET", "/todos"], ["GET", "/todos/7"], ["DELETE", "/todos/7"],
["PUT", "/todos/7"], ["GET", "/users"]]) {
const res = await fetch(base + path, { method });
const allow = res.headers.get("allow");
seen.push(res.status);
console.log(`${(method + " " + path).padEnd(16)} ${res.status} ${await res.text()}${allow ? ` (allow: ${allow})` : ""}`);
}
server.close();
check("routes, a parameter, the wrong method, the wrong path", seen, [200, 200, 200, 405, 404]);
What node printed, when the build ran it
node example.mjs
GET /todos 200 all the todos GET /todos/7 200 todo number 7 DELETE /todos/7 200 deleted todo 7 PUT /todos/7 405 that path does not take that method (allow: GET, DELETE) GET /users 404 no such path ok routes, a parameter, the wrong method, the wrong path
:id matches one path segment and hands it over by name, as a string. The router
also tells two different failures apart for free. A path no route matches is a 404; a path that
exists, asked for with a method it does not support, is a 405 — and the allow header
lists the methods that would have worked, taken straight from the routes.
A whole API
Those pieces together make a small to-do API in app.mjs. It follows the conventions
called REST: paths name things (/todos, /todos/1), methods
say what to do with them, and status codes say how it went.
| Request | Does | Answers |
|---|---|---|
GET /todos | list them, optionally ?done=true | 200 and an array |
POST /todos | create one | 201, the new todo, and a location header |
GET /todos/:id | fetch one | 200, or 404 |
PATCH /todos/:id | change some fields | 200 and the updated todo |
DELETE /todos/:id | remove it | 204 and no body |
app.mjs
import { createServer } from "node:http";
export class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// ---- JSON in and out ------------------------------------------------------
async function readJson(req, limit = 10_000) {
if (!req.headers["content-type"]?.startsWith("application/json")) {
throw new HttpError(415, "send JSON, with Content-Type: application/json");
}
let size = 0;
const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > limit) throw new HttpError(413, `the body is over ${limit} bytes`);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new HttpError(400, "the body is not valid JSON");
}
}
function send(res, status, body, headers = {}) {
if (body === undefined) {
res.writeHead(status, headers);
return res.end();
}
const text = JSON.stringify(body);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(text),
...headers,
});
res.end(text);
}
// ---- validation -------------------------------------------------------------
function checkTodo(input, { partial = false } = {}) {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new HttpError(400, "send a JSON object");
}
const unknown = Object.keys(input).filter((key) => key !== "title" && key !== "done");
if (unknown.length > 0) throw new HttpError(400, `unknown field: ${unknown.join(", ")}`);
if (!partial || "title" in input) {
if (typeof input.title !== "string" || input.title.trim() === "") {
throw new HttpError(400, "title must be a non-empty string");
}
}
if ("done" in input && typeof input.done !== "boolean") {
throw new HttpError(400, "done must be true or false");
}
return input;
}
// ---- the app ------------------------------------------------------------------
export function createApp() {
const todos = new Map(); // the data lives here, one store per app
let nextId = 1;
const routes = [];
const route = (method, pathname, handler) =>
routes.push({ method, pattern: new URLPattern({ pathname }), handler });
const find = (id) => {
const todo = todos.get(Number(id));
if (!todo) throw new HttpError(404, `no todo with id ${id}`);
return todo;
};
route("GET", "/todos", ({ url }) => {
let list = [...todos.values()];
const done = url.searchParams.get("done");
if (done !== null) list = list.filter((todo) => String(todo.done) === done);
return { status: 200, body: list };
});
route("POST", "/todos", async ({ req }) => {
const input = checkTodo(await readJson(req));
const todo = { id: nextId++, title: input.title.trim(), done: input.done ?? false };
todos.set(todo.id, todo);
return { status: 201, body: todo, headers: { location: `/todos/${todo.id}` } };
});
route("GET", "/todos/:id(\\d+)", ({ params }) => ({ status: 200, body: find(params.id) }));
route("PATCH", "/todos/:id(\\d+)", async ({ req, params }) => {
const todo = find(params.id);
const changes = checkTodo(await readJson(req), { partial: true });
if ("title" in changes) todo.title = changes.title.trim();
if ("done" in changes) todo.done = changes.done;
return { status: 200, body: todo };
});
route("DELETE", "/todos/:id(\\d+)", ({ params }) => {
find(params.id);
todos.delete(Number(params.id));
return { status: 204 };
});
return createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
const matches = routes.filter((r) => r.pattern.test({ pathname: url.pathname }));
if (matches.length === 0) throw new HttpError(404, `nothing at ${url.pathname}`);
const match = matches.find((r) => r.method === req.method);
if (!match) {
res.setHeader("allow", matches.map((r) => r.method).join(", "));
throw new HttpError(405, `${req.method} is not allowed here`);
}
const params = match.pattern.exec({ pathname: url.pathname }).pathname.groups;
const { status, body, headers } = await match.handler({ req, url, params });
send(res, status, body, headers);
} catch (err) {
if (err instanceof HttpError) return send(res, err.status, { error: err.message });
console.error(err);
send(res, 500, { error: "internal error" });
}
});
}
example.mjs
import { once } from "node:events";
import { createApp } from "./app.mjs";
const server = createApp().listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;
async function call(method, path, body) {
const res = await fetch(base + path, {
method,
headers: body === undefined ? {} : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
console.log(`${method} ${path}${body === undefined ? "" : " " + JSON.stringify(body)}`);
console.log(` ${res.status} ${text}`);
return { status: res.status, location: res.headers.get("location"), data: text ? JSON.parse(text) : null };
}
const tea = await call("POST", "/todos", { title: "Buy tea" });
const report = await call("POST", "/todos", { title: "Write the report" });
await call("PATCH", `/todos/${tea.data.id}`, { done: true });
const finished = await call("GET", "/todos?done=true");
const empty = await call("POST", "/todos", { title: " " });
const sneaky = await call("POST", "/todos", { title: "Pay me", done: false, owner: "admin" });
const removed = await call("DELETE", `/todos/${report.data.id}`);
const gone = await call("GET", `/todos/${report.data.id}`);
const notANumber = await call("GET", "/todos/abc");
const all = await call("GET", "/todos");
server.close();
check("POST answers 201 and says where the new todo lives", [tea.status, tea.location], [201, "/todos/1"]);
check("the filter found the finished one", finished.data, [{ id: 1, title: "Buy tea", done: true }]);
check("a blank title and an unknown field are both refused", [empty.status, sneaky.status], [400, 400]);
check("DELETE answers 204 with no body", [removed.status, removed.data], [204, null]);
check("afterwards the todo is gone, and ids must be numbers", [gone.status, notANumber.status], [404, 404]);
check("one todo is left", all.data.length, 1);
What node printed, when the build ran it
node example.mjs
POST /todos {"title":"Buy tea"}
201 {"id":1,"title":"Buy tea","done":false}
POST /todos {"title":"Write the report"}
201 {"id":2,"title":"Write the report","done":false}
PATCH /todos/1 {"done":true}
200 {"id":1,"title":"Buy tea","done":true}
GET /todos?done=true
200 [{"id":1,"title":"Buy tea","done":true}]
POST /todos {"title":" "}
400 {"error":"title must be a non-empty string"}
POST /todos {"title":"Pay me","done":false,"owner":"admin"}
400 {"error":"unknown field: owner"}
DELETE /todos/2
204
GET /todos/2
404 {"error":"no todo with id 2"}
GET /todos/abc
404 {"error":"nothing at /todos/abc"}
GET /todos
200 [{"id":1,"title":"Buy tea","done":true}]
ok POST answers 201 and says where the new todo lives
ok the filter found the finished one
ok a blank title and an unknown field are both refused
ok DELETE answers 204 with no body
ok afterwards the todo is gone, and ids must be numbers
ok one todo is left
The session reads like a conversation. Creating a todo answers 201 (Created) with a
location header pointing at it. PATCH changes only the fields it is sent.
Deleting answers 204 (No Content): success, and nothing to say. Asking for the deleted one is a 404,
and so is /todos/abc, because the pattern :id(\d+) only matches digits —
the route never sees it.
Two of the requests were refused on purpose, and the second matters more than it looks. It tried
to set an owner field the API does not have. Code that copies a request body straight
into a stored object — Object.assign(todo, body) — would have accepted it, and in a real
app the field would be role or isAdmin. This is called mass
assignment, and the defence is the one in checkTodo: name the fields you accept,
check the type of each, and refuse everything else. Lesson 40 shows what a copied
__proto__ field does.
The data lives in a Map inside createApp, so it disappears when the
process stops, and each call to createApp starts empty. Lesson 39 stores it in a real
database, and lesson 41 tests this very file, where a fresh app per test is exactly what you want.
What a framework changes
import express from "express";
const app = express();
app.use(express.json({ limit: "10kb" })); // readJson, with the 413 and 400
app.get("/todos/:id", (req, res) => { // the router, with req.params.id
const todo = todos.get(Number(req.params.id));
if (!todo) return res.status(404).json({ error: "not found" });
res.json(todo); // sendJson
});
app.listen(3000);
Express is a package from the npm registry, and this site installs nothing from it. Everything this listing does is done by hand, and run, in the panels above.
The shape is the same: a JSON body parser with a size limit, routes with named parameters, and helpers to send JSON with a status. A framework saves you writing those, and adds a great deal more — middleware, logging, validation libraries. It does not change what happens on the wire, and when it behaves strangely, what you now know is what you debug it with.
- Read a JSON body with a size limit and a content-type check. Answer 400, 413 or 415, never a crash.
- Throw an error carrying a status for expected problems; log and 500 everything else.
URLPatternmatches paths like/todos/:id. A known path with the wrong method is 405, with anallowheader.- REST: nouns in paths, methods as verbs. 201 and
locationfor a create, 204 for a delete. - Accept only the fields you expect, and check each one. Never copy a body into your data.
38 fetch, and talking to other servers
fetch is how a Node program talks to other servers: a payment
provider, a weather API, another service in your own system. It is the same function browsers have,
built into Node. Calling it is one line. Calling it reliably — when the other server is slow,
down, or answering with errors — is what this lesson is about.
This site has no network, so in every panel the "other server" is one the panel starts itself on
127.0.0.1. The fetch code is exactly what you would write against a real
one.
Getting JSON
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
// Standing in for someone else's API.
const api = createServer((req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ id: 1, name: "Ada Lovelace", languages: ["en", "fr"] }));
});
api.listen(0, "127.0.0.1");
await once(api, "listening");
const res = await fetch(`http://127.0.0.1:${api.address().port}/users/1`);
console.log("status:", res.status, res.statusText, " ok:", res.ok);
console.log("content-type:", res.headers.get("content-type"));
const user = await res.json();
console.log(user);
api.close();
check("res.json() parsed the body", user.name, "Ada Lovelace");
What node printed, when the build ran it
node example.mjs
status: 200 OK ok: true
content-type: application/json
{ id: 1, name: 'Ada Lovelace', languages: [ 'en', 'fr' ] }
ok res.json() parsed the body
A call is two awaits. await fetch(url) finishes as soon as the status line and headers
arrive, which is why res.status and res.headers are available straight away.
The body may still be on its way, so reading it is a second await: res.json(),
res.text(), or res.arrayBuffer() for bytes.
A 404 is not an error
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
const api = createServer((req, res) => {
res.writeHead(req.url === "/users/1" ? 200 : 404, { "content-type": "application/json" });
res.end(JSON.stringify(req.url === "/users/1" ? { name: "Ada" } : { error: "no such user" }));
});
api.listen(0, "127.0.0.1");
await once(api, "listening");
const base = `http://127.0.0.1:${api.address().port}`;
// The way it is usually written first.
const careless = await (await fetch(`${base}/users/99`)).json();
console.log(`Hello, ${careless.name}`);
// Check the status before trusting the body.
async function getJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url} failed: ${res.status} ${res.statusText}`);
return res.json();
}
let failure;
try {
await getJson(`${base}/users/99`);
} catch (err) {
failure = err.message.replace(base, "");
console.log(failure);
}
api.close();
check("fetch resolved happily with the 404, and the name was undefined", careless.name, undefined);
check("getJson turned it into an error", failure, "GET /users/99 failed: 404 Not Found");
What node printed, when the build ran it
node example.mjs
Hello, undefined GET /users/99 failed: 404 Not Found ok fetch resolved happily with the 404, and the name was undefined ok getJson turned it into an error
This catches almost everyone once. fetch rejects only when it could not get a response
at all. A 404 or a 500 is a response, so the promise resolves, the error body parses as JSON
perfectly well, and the program carries on greeting undefined. res.ok is
true for statuses 200 to 299; check it, or wrap fetch in a helper that does,
every time.
When the other server is down
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
// A port nothing is listening on: ask for a free one, then close it.
const probe = createServer().listen(0, "127.0.0.1");
await once(probe, "listening");
const { port } = probe.address();
probe.close();
await once(probe, "close");
const res = await fetch(`http://127.0.0.1:${port}/prices`);
console.log(await res.json());
What node printed as it died, when the build ran it
node example.mjs
node:internal/modules/run_main:107
triggerUncaughtException(
^
[TypeError: fetch failed] {
[cause]: Error: connect ECONNREFUSED 127.0.0.1:53047
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:2017:16) {
errno: -61,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 53047
}
}
Node.js v26.7.0
When there is no response at all, fetch rejects, always with the same unhelpful
TypeError: fetch failed. The real reason is attached as err.cause, and Node
prints it underneath: ECONNREFUSED, a connection refused because nothing was listening.
Other causes you will meet are ENOTFOUND (the hostname does not exist — often a typo),
ECONNRESET (the other side hung up mid-response) and certificate errors. Always log
err.cause; the message on its own tells you nothing.
Timeouts
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
let serverNoticed = false;
const api = createServer((req, res) => {
// A slow endpoint: it answers after five seconds...
const timer = setTimeout(() => res.end("finally"), 5000);
// ...unless the client gives up first, in which case it stops working.
res.on("close", () => {
if (!res.writableFinished) serverNoticed = true;
clearTimeout(timer);
});
});
api.listen(0, "127.0.0.1");
await once(api, "listening");
const started = performance.now();
let outcome;
try {
await fetch(`http://127.0.0.1:${api.address().port}/report`, { signal: AbortSignal.timeout(200) });
outcome = "answered";
} catch (err) {
outcome = err.name;
}
const waited = Math.round(performance.now() - started);
console.log(`${outcome} after ${Math.round(waited / 100) * 100} ms`);
await new Promise((resolve) => setTimeout(resolve, 20));
console.log("the server saw the client leave:", serverNoticed);
api.close();
check("the request was abandoned with a TimeoutError", outcome, "TimeoutError");
check("after about 200 ms, not five seconds", waited < 1000, true);
check("and the server stopped working on it", serverNoticed, true);
What node printed, when the build ran it
node example.mjs
TimeoutError after 200 ms the server saw the client leave: true ok the request was abandoned with a TimeoutError ok after about 200 ms, not five seconds ok and the server stopped working on it
fetch has no short timeout of its own: against a server that accepts the connection
and then says nothing, it waits for minutes. AbortSignal.timeout(ms), from lesson 28, is
the fix, and every call to a server you do not control should have one. When the signal fires the
promise rejects with a TimeoutError and the connection is closed. The server side shows
the other half: res emits "close" when the client goes away, which is the
moment to stop doing expensive work nobody will receive.
Retrying, carefully
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
import { setTimeout as sleep } from "node:timers/promises";
const calls = { "/flaky": 0, "/missing": 0 };
const api = createServer((req, res) => {
calls[req.url]++;
if (req.url === "/flaky" && calls["/flaky"] <= 2) res.writeHead(503); // busy, twice
else if (req.url === "/missing") res.writeHead(404);
res.end();
});
api.listen(0, "127.0.0.1");
await once(api, "listening");
const base = `http://127.0.0.1:${api.address().port}`;
async function fetchWithRetry(url, { attempts = 4, baseDelay = 100 } = {}) {
for (let attempt = 1; ; attempt++) {
let res, error;
try {
res = await fetch(url, { signal: AbortSignal.timeout(2000) });
} catch (err) {
error = err;
}
// Worth another try: no response at all, "too many requests", or a server error.
const retryable = error !== undefined || res.status === 429 || res.status >= 500;
if (!retryable || attempt === attempts) {
if (error) throw error;
return res;
}
await res?.body?.cancel(); // free the connection
const delay = baseDelay * 2 ** (attempt - 1); // 100, 200, 400 ...
console.log(` attempt ${attempt}: ${error ? error.name : res.status}, waiting ${delay} ms`);
await sleep(delay);
}
}
console.log("GET /flaky");
const flaky = await fetchWithRetry(base + "/flaky");
console.log(" finally:", flaky.status);
console.log("GET /missing");
const missing = await fetchWithRetry(base + "/missing");
console.log(" finally:", missing.status);
api.close();
check("the busy server was tried three times and then answered", [calls["/flaky"], flaky.status], [3, 200]);
check("a 404 was not retried at all", [calls["/missing"], missing.status], [1, 404]);
What node printed, when the build ran it
node example.mjs
GET /flaky attempt 1: 503, waiting 100 ms attempt 2: 503, waiting 200 ms finally: 200 GET /missing finally: 404 ok the busy server was tried three times and then answered ok a 404 was not retried at all
A 503 or a dropped connection is often temporary, so trying again is reasonable — but only with a growing wait between attempts, called exponential backoff, so that a struggling server is not hammered by every client at once. (Real code also adds a little randomness to each delay, so clients that failed together do not retry together.) A 404 or a 400 means the request itself is wrong, and sending the same wrong request again will get the same answer, so it is returned at once.
Only retry what is safe to repeat. A GET can be sent twice without
harm. A POST that timed out may still have succeeded on the other side, and retrying it
can charge a card twice. Payment and messaging APIs accept an idempotency key header for
exactly this: send the same key with each retry, and the server does the work at most once.
Many requests at once
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
let inFlight = 0;
let peak = 0;
const api = createServer((req, res) => {
inFlight++;
peak = Math.max(peak, inFlight);
setTimeout(() => { inFlight--; res.end(req.url); }, 50); // 50 ms per request
});
api.listen(0, "127.0.0.1");
await once(api, "listening");
const base = `http://127.0.0.1:${api.address().port}`;
const ids = Array.from({ length: 20 }, (_, i) => i + 1);
const get = (id) => fetch(`${base}/items/${id}`).then((res) => res.text());
// At most `limit` requests in flight; results stay in order.
async function mapWithLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
}
await Promise.all(Array.from({ length: limit }, worker));
return results;
}
const runs = {};
async function measure(name, work) {
peak = 0;
const started = performance.now();
const results = await work();
const ms = performance.now() - started;
runs[name] = { peak, results, ms };
console.log(`${name.padEnd(14)} ${String(peak).padStart(2)} at once, ${Math.round(ms / 10) * 10} ms`);
}
await measure("one by one", async () => { const r = []; for (const id of ids) r.push(await get(id)); return r; });
await measure("Promise.all", () => Promise.all(ids.map(get)));
await measure("four at a time", () => mapWithLimit(ids, 4, get));
api.close();
const expected = ids.map((id) => `/items/${id}`);
check("one by one never overlapped, and took over a second", [runs["one by one"].peak, runs["one by one"].ms >= 1000], [1, true]);
check("Promise.all sent all twenty at once, in a fraction of that", [runs["Promise.all"].peak, runs["Promise.all"].ms < 500], [20, true]);
check("the pool never had more than four", runs["four at a time"].peak, 4);
check("and every approach kept the results in order", [runs["Promise.all"].results, runs["four at a time"].results], [expected, expected]);
What node printed, when the build ran it
node example.mjs
one by one 1 at once, 1080 ms Promise.all 20 at once, 70 ms four at a time 4 at once, 270 ms ok one by one never overlapped, and took over a second ok Promise.all sent all twenty at once, in a fraction of that ok the pool never had more than four ok and every approach kept the results in order
Twenty 50 ms requests one after another take a second. Promise.all (lesson 26) sends
them together and finishes in about the time of one — and the server counted all twenty arriving
at once. Against your own server that is fine. Against someone else's API, twenty becomes two
thousand the day your list grows, and the answer is a wall of 429s or a blocked key. A small pool
like mapWithLimit gets most of the speed while keeping a promise about how hard you hit
the other side.
Sending data, and a token
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
// The other service: it wants a bearer token, and a JSON body.
const api = createServer(async (req, res) => {
if (req.headers.authorization !== `Bearer ${process.env.API_TOKEN}`) {
res.writeHead(401, { "content-type": "application/json" });
return res.end(JSON.stringify({ error: "bad or missing token" }));
}
let body = "";
for await (const chunk of req) body += chunk;
res.writeHead(201, { "content-type": "application/json" });
res.end(JSON.stringify({ id: "msg_1", ...JSON.parse(body) }));
});
api.listen(0, "127.0.0.1");
await once(api, "listening");
const url = `http://127.0.0.1:${api.address().port}/messages`;
async function send(message, token) {
const res = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
...(token && { authorization: `Bearer ${token}` }),
},
body: JSON.stringify(message),
});
return { status: res.status, body: await res.json() };
}
const without = await send({ to: "ada", text: "hi" });
const withToken = await send({ to: "ada", text: "hi" }, process.env.API_TOKEN);
console.log("no token: ", without);
console.log("with token:", withToken);
api.close();
check("refused without the token", without.status, 401);
check("accepted with it", withToken, { status: 201, body: { id: "msg_1", to: "ada", text: "hi" } });
What node printed, when the build ran it
API_TOKEN=demo-token-not-a-real-one node example.mjs
no token: { status: 401, body: { error: 'bad or missing token' } }
with token: { status: 201, body: { id: 'msg_1', to: 'ada', text: 'hi' } }
ok refused without the token
ok accepted with it
A request with a body needs three things: a method, a body — which
fetch sends as-is, so JSON must be stringified — and a content-type header
saying what it is. Leave the header out and the server from lesson 37 answers 415. The token came from
an environment variable (lesson 31), which is the only place a token belongs: not in the code, not in
git, and not in a log line.
Two smaller traps
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
const api = createServer((req, res) => {
if (req.url === "/user") {
res.writeHead(200, { "content-type": "application/json" });
return res.end('{ "name": "Ada" }');
}
// What a proxy or a crashed server often sends instead of JSON.
res.writeHead(502, { "content-type": "text/html" });
res.end("<html><body>Bad Gateway</body></html>");
});
api.listen(0, "127.0.0.1");
await once(api, "listening");
const base = `http://127.0.0.1:${api.address().port}`;
const errors = [];
async function attempt(label, work) {
try {
await work();
} catch (err) {
errors.push(err.name);
console.log(`${label}: ${err.name}: ${err.message}`);
}
}
const res = await fetch(`${base}/user`);
console.log("first read:", await res.json());
await attempt("second read", () => res.json());
await attempt("HTML as JSON", async () => (await fetch(`${base}/orders`)).json());
api.close();
check("a body can only be read once", errors[0], "TypeError");
check("and an HTML error page is not JSON", errors[1], "SyntaxError");
What node printed, when the build ran it
node example.mjs
first read: { name: 'Ada' }
second read: TypeError: Body is unusable: Body has already been read
HTML as JSON: SyntaxError: Unexpected token '<', "<html><bod"... is not valid JSON
ok a body can only be read once
ok and an HTML error page is not JSON
A response body is a stream, so it can be read once; keep what res.json() gave you
rather than calling it again (or call res.clone() first if you truly need two reads). And
an error often arrives as an HTML page from a proxy in front of the API rather than as JSON from the
API itself, so res.json() fails with a SyntaxError that hides the real
problem, a 502. Checking res.ok before parsing, as getJson did, reports the
502 instead.
await fetch()gives you the status and headers; reading the body is a second await, and can happen once.fetchresolves for 404 and 500. Checkres.ok.- No response at all is
TypeError: fetch failed. The reason is inerr.cause. - Give every call a timeout with
AbortSignal.timeout. - Retry network errors, 429 and 5xx, with backoff, and only for requests that are safe to repeat.
- Limit how many requests you have in flight against someone else's server.