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.
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
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
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
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.
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.
| Code | Means |
|---|---|
| 200 / 201 / 204 | Fine · Created, with a Location · Done, no body |
| 301 / 302 | Moved for good · Moved for now |
| 400 | The request itself is wrong |
| 401 / 403 | Not authenticated · Authenticated, not allowed |
| 404 / 405 | No such resource · Wrong method for this one |
| 409 / 422 | Conflicts with what exists · Understood, but invalid |
| 429 | Too many requests |
| 500 / 503 | We 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.
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
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
});