Emmanuel Akinfulubi

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

createServer, listen, and one request

ran at build time

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.

A request and a response, as text

ran at build time

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

Method, path, query string, headers and body

ran at build time

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

writeHead, and a router made of ifs

ran at build time

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.

CodeMeansSend it when
200OKit worked, and here is the result
201Createda POST created something
204No Contentit worked and there is nothing to send back, such as after a delete
301Moved Permanentlythe thing lives at the location header now
400Bad Requestthe client sent something invalid
401Unauthorizedyou do not know who they are: they need to log in
403Forbiddenyou know who they are, and they may not do this
404Not Foundthere is nothing at that path
405Method Not Allowedthe path exists, but not for that method
500Internal Server Erroryour code failed; never the client's fault

The names in that table, from http.STATUS_CODES

ran at build time

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

A missing return

dies with ERR_HTTP_HEADERS_SENT

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

An exception in a handler

dies with TypeError

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:

Catching per request

ran at build time

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.

node server.mjs, started, used and stopped

ran at build time

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 with req and res for every request; listen(port, host) starts it.
  • HTTP is text: a request line or status line, headers, a blank line, a body.
  • Parse req.url with new URL(req.url, base). Query values are strings. The body is a stream.
  • writeHead sets status and headers. Always return 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

readJson and sendJson

ran at build time

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:

Routing with URLPattern

ran at build time

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.

RequestDoesAnswers
GET /todoslist them, optionally ?done=true200 and an array
POST /todoscreate one201, the new todo, and a location header
GET /todos/:idfetch one200, or 404
PATCH /todos/:idchange some fields200 and the updated todo
DELETE /todos/:idremove it204 and no body

A to-do API, and a client session against it

ran at build time

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

The same ideas in Express

shown, not run
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.
  • URLPattern matches paths like /todos/:id. A known path with the wrong method is 405, with an allow header.
  • REST: nouns in paths, methods as verbs. 201 and location for 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

fetch, and a JSON response

ran at build time

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

fetch resolves for every status

ran at build time

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

fetch failed

dies with TypeError

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

AbortSignal.timeout

ran at build time

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

Retry with exponential backoff

ran at build time

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

One by one, all at once, and a pool

ran at build time

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

POST with JSON and an Authorization header

ran at build time

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

Reading a body twice, and parsing an error page

ran at build time

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.
  • fetch resolves for 404 and 500. Check res.ok.
  • No response at all is TypeError: fetch failed. The reason is in err.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.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.