An HTTP server

Answer an HTTP request with the right status, headers and body — without a framework.

07of 18
≈ 12Min read
7Parts

The smallest server

The handler runs for every request. req is a readable stream with the details of the request; res is a writable stream you must finish with end() — a response that is never ended is a request that hangs until it times out.

no dependencies at all
import http from "node:http";

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  res.end("Doors");
});

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

What is on the request

the parts you route on
req.method          // "GET", "POST", …
req.url             // "/doors/12?full=1" — the path AND the query, never the host
req.headers         // lower-cased keys: req.headers["content-type"]
req.socket.remoteAddress

const url = new URL(req.url, `http://${req.headers.host}`);
url.pathname        // "/doors/12"
url.searchParams.get("full")   // "1"

Reading a body

it arrives in pieces
async function readBody(req) {
  let data = "";
  for await (const chunk of req) data += chunk;      // req is a stream
  return data;
}

const raw = await readBody(req);
const door = JSON.parse(raw);        // and this can throw — see below
A body is unbounded until you bound it

Nothing stops a client sending a gigabyte. Count the bytes as they arrive and destroy the request past a limit — a framework does this for you, which is one reason to use one.

Writing a response

Headers must be sent before any body. After the first write() they are on their way, and setting another one throws.

status, headers, body — in that order
res.writeHead(201, {
  "Content-Type": "application/json; charset=utf-8",
  "Location": "/doors/12",
});
res.end(JSON.stringify(door));

// or set them one at a time, before the first write
res.statusCode = 404;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Not found" }));

The status codes worth knowing by heart

Answering 200 with { "error": … } in the body is the single most common API mistake: every client has to parse the body to find out whether it worked.

CodeMeans
200 / 201 / 204Fine · Created, with a Location · Done, no body
301 / 302Moved for good · Moved for now
400The request itself is wrong
401 / 403Not authenticated · Authenticated, not allowed
404 / 405No such resource · Wrong method for this one
409 / 422Conflicts with what exists · Understood, but invalid
429Too many requests
500 / 503We broke · We are down or overloaded

Routing by hand

Twenty lines and you have a router. Express exists because the next twenty are the awkward ones: nested routers, body parsing, error handling, static files and a hundred small behaviours you would otherwise write twice.

what a framework is doing for you
const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  const path = url.pathname;

  if (req.method === "GET" && path === "/doors") return sendJson(res, 200, doors);

  const match = path.match(/^\/doors\/([^/]+)$/);
  if (req.method === "GET" && match) {
    const door = doors.find((d) => d.id === match[1]);
    return door ? sendJson(res, 200, door) : sendJson(res, 404, { error: "Not found" });
  }

  sendJson(res, 404, { error: "Not found" });
});

function sendJson(res, status, value) {
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(value));
}

Closing down properly

a deploy should not cut requests off
process.on("SIGTERM", () => {
  server.close(() => process.exit(0));   // stop accepting, finish what is in flight
  setTimeout(() => process.exit(1), 10_000).unref();   // but do not hang forever
});

Practice