Every Node web framework, Express included, is a layer over this module; knowing it makes frameworks unmysterious.
http.createServer(handler) returns a server that calls handler(req, res) for each request. server.listen(port) starts accepting connections.
reqis an IncomingMessage (a readable stream):req.method,req.url(path plus query string, e.g./users?page=2), andreq.headerswith lower-cased names.resis a ServerResponse (a writable stream): setres.statusCodeor callres.writeHead(status, headers),res.setHeader(name, value), write withres.write(chunk), and always finish withres.end(body).
Parse the URL with the WHATWG URL class: new URL(req.url, "http://localhost") gives you pathname and searchParams.
The request body is not parsed for you — it arrives as a stream. Collect it with for await (const chunk of req), concatenate the Buffers, then JSON.parse. Cap the size so a client can't send you a gigabyte, and respond 400 on invalid JSON.
Things the raw module leaves to you, which frameworks provide: routing, body parsing, content-type negotiation, cookies, error handling. Note also:
- Every code path must end the response, or the client hangs until it times out.
- A thrown error inside an
asynchandler is an unhandled rejection — wrap the handler in try/catch and answer 500. - Headers must be set before the first
write; afterwardsres.headersSentis true and setting them throws.
The node:https module works the same with TLS certificates, and node:http2 supports HTTP/2. In production you'll often run plain HTTP behind a load balancer that terminates TLS.
Example
import { createServer } from "node:http";
const todos = [{ id: 1, title: "learn node:http" }];
async function readJson(req, limit = 1_000_000) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
if ((size += chunk.length) > limit) throw Object.assign(new Error("too large"), { status: 413 });
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
const send = (res, status, data) =>
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(data));
const server = createServer(async (req, res) => {
try {
const { pathname } = new URL(req.url, "http://localhost");
if (req.method === "GET" && pathname === "/todos") return send(res, 200, todos);
if (req.method === "POST" && pathname === "/todos") {
const todo = { id: todos.length + 1, title: (await readJson(req)).title };
todos.push(todo);
return send(res, 201, todo);
}
send(res, 404, { error: "not found" });
} catch (err) {
send(res, err.status ?? (err instanceof SyntaxError ? 400 : 500), { error: err.message });
}
});
server.listen(3000, () => console.log("http://localhost:3000/todos"));Remember
reqis a readable stream,resa writable stream- You must parse URLs and bodies yourself, and always
res.end() - Header names in
req.headersare lower-case - Catch errors in async handlers and answer with a status code
Watch out for
- Forgetting to end the response on one code path, leaving the client hanging.
- Reading request bodies without a size limit.