Building an HTTP server with node:http

Node.js · Practical · 14 min

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.

  • req is an IncomingMessage (a readable stream): req.method, req.url (path plus query string, e.g. /users?page=2), and req.headers with lower-cased names.
  • res is a ServerResponse (a writable stream): set res.statusCode or call res.writeHead(status, headers), res.setHeader(name, value), write with res.write(chunk), and always finish with res.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 async handler is an unhandled rejection — wrap the handler in try/catch and answer 500.
  • Headers must be set before the first write; afterwards res.headersSent is 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

  • req is a readable stream, res a writable stream
  • You must parse URLs and bodies yourself, and always res.end()
  • Header names in req.headers are 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.

Check yourself

  1. A client sends JSON in a POST. Where is it in a plain node:http handler?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/learn/http/anatomy-of-an-http-transaction