Skip to main content
PProfileMatch

Build a Node.js HTTP Server with the Built-in http Module

Web DevelopmentLesson 4 of 45 min read

What you'll learn

  • Create a server with http.createServer and send JSON responses with correct headers
  • Read and validate a request body safely, including a size limit
  • Choose accurate status codes (400, 404, 405, 409, 413) for failures
  • Test the service with curl and shut it down cleanly

On this page

  1. What a request looks like to Node
  2. The locker service
  3. Trying it with curl
  4. Why each status code was chosen
  5. Shutting down and testing
  6. Common mistakes and the symptoms they produce
  7. How this shows up in real projects and interviews

A company installs parcel lockers in office lobbies. Each locker has a size, and a courier needs to find a free one, reserve it for a parcel, and later the recipient collects it. The vendor's software is a black box, so you are asked for a tiny service that exposes just those operations over HTTP. Before reaching for Express, build it with the module Node already ships. It takes about a hundred lines, and afterwards no framework will feel like magic.

What a request looks like to Node

http.createServer takes a handler that Node calls once per incoming request with two objects. req is an IncomingMessage: a readable stream carrying the method, URL, headers and body. res is a ServerResponse: a writable stream you write status, headers and body into. The body arrives in chunks, which is why the previous lesson on streams was not optional. The handler must end the response with res.end(), otherwise the client waits until it times out.

The locker service

The state lives in a Map in memory. Three small helpers do the repetitive work: an HttpError class so any code can abort with a status, a send function that serialises JSON and sets content-type and content-length, and readJson, which collects the body, enforces a 4 KiB limit and turns bad JSON into a 400. The route function then does explicit matching on method and path.

server.mjs
import { createServer } from "node:http";

const lockers = new Map([
  [1, { id: 1, size: "S", parcelId: null }],
  [2, { id: 2, size: "M", parcelId: null }],
  [3, { id: 3, size: "M", parcelId: "PK-881" }],
  [4, { id: 4, size: "L", parcelId: null }],
]);

class HttpError extends Error {
  constructor(status, message, headers = {}) {
    super(message);
    this.status = status;
    this.headers = headers;
  }
}

function send(res, status, body, headers = {}) {
  const payload = JSON.stringify(body);
  res.writeHead(status, {
    "content-type": "application/json; charset=utf-8",
    "content-length": Buffer.byteLength(payload),
    ...headers,
  });
  res.end(payload);
}

async function readJson(req, limit = 4096) {
  let size = 0;
  const chunks = [];
  for await (const chunk of req) {
    size += chunk.length;
    if (size > limit) throw new HttpError(413, "body too large");
    chunks.push(chunk);
  }
  try {
    return JSON.parse(Buffer.concat(chunks).toString("utf8"));
  } catch {
    throw new HttpError(400, "body is not valid JSON");
  }
}

async function route(req, res) {
  const url = new URL(req.url, "http://kiosk.local");
  const { pathname } = url;

  if (pathname === "/lockers") {
    if (req.method !== "GET") throw new HttpError(405, "use GET", { allow: "GET" });
    const size = url.searchParams.get("size");
    const free = [...lockers.values()].filter(
      (l) => l.parcelId === null && (!size || l.size === size)
    );
    return send(res, 200, { free });
  }

  const match = pathname.match(/^\/lockers\/(\d+)\/(reserve|collect)$/);
  if (match) {
    if (req.method !== "POST") throw new HttpError(405, "use POST", { allow: "POST" });
    const locker = lockers.get(Number(match[1]));
    if (!locker) throw new HttpError(404, "no such locker");
    const { parcelId } = await readJson(req);
    if (typeof parcelId !== "string" || parcelId === "") {
      throw new HttpError(400, "parcelId (string) is required");
    }
    if (match[2] === "reserve") {
      if (locker.parcelId) throw new HttpError(409, "locker already occupied");
      locker.parcelId = parcelId;
      return send(res, 201, locker);
    }
    if (locker.parcelId !== parcelId) throw new HttpError(409, "parcel is not in this locker");
    locker.parcelId = null;
    return send(res, 200, { collected: parcelId });
  }

  throw new HttpError(404, "no such route");
}

const server = createServer(async (req, res) => {
  try {
    await route(req, res);
  } catch (err) {
    if (err instanceof HttpError) {
      return send(res, err.status, { error: err.message }, err.headers);
    }
    console.error(err);
    send(res, 500, { error: "internal error" });
  }
});

const port = Number(process.env.PORT ?? 3100);
server.listen(port, () => console.log(`kiosk API on http://localhost:${port}`));

process.on("SIGINT", () => {
  console.log("shutting down");
  server.close(() => process.exit(0));
  server.closeIdleConnections();
});

Start it with node server.mjs (set PORT to change the port). Because this file uses import, it needs the .mjs extension or "type": "module", as covered in the modules lesson. Add --watch while developing and Node restarts it when files change.

Trying it with curl

These requests ran against the real server. -w prints the HTTP status after each body, and -i on the DELETE request also prints the response headers.

session.sh
# -s hides the progress meter, -w prints the status code after the body
curl -s -w "\n-> %{http_code}\n" "http://localhost:3100/lockers?size=M"
curl -s -w "\n-> %{http_code}\n" -X POST http://localhost:3100/lockers/2/reserve \
  -H "content-type: application/json" -d '{"parcelId":"PK-902"}'
curl -s -w "\n-> %{http_code}\n" -X POST http://localhost:3100/lockers/2/reserve \
  -H "content-type: application/json" -d '{"parcelId":"PK-903"}'
curl -s -w "\n-> %{http_code}\n" -X POST http://localhost:3100/lockers/2/reserve -d '{oops'
curl -s -w "\n-> %{http_code}\n" -X POST http://localhost:3100/lockers/9/reserve -d '{"parcelId":"x"}'
curl -s -i -w "
" -X DELETE http://localhost:3100/lockers
curl -s -w "\n-> %{http_code}\n" -X POST http://localhost:3100/lockers/2/collect -d '{"parcelId":"PK-902"}'

Output

{"free":[{"id":2,"size":"M","parcelId":null}]}
-> 200
{"id":2,"size":"M","parcelId":"PK-902"}
-> 201
{"error":"locker already occupied"}
-> 409
{"error":"body is not valid JSON"}
-> 400
{"error":"no such locker"}
-> 404
HTTP/1.1 405 Method Not Allowed
content-type: application/json; charset=utf-8
content-length: 19
allow: GET

{"error":"use GET"}
{"collected":"PK-902"}
-> 200

Not shown above: a request whose body is about 6,000 bytes long to a reserve route came back as {"error":"body too large"} with status 413, and GET /nothing returned 404 with {"error":"no such route"}.

Why each status code was chosen

SituationStatusReasoning
Free lockers listed200Normal read
Locker reserved201A new reservation now exists
Body is not JSON, or parcelId missing400The client sent something we cannot interpret
Unknown locker or route404Nothing at that address
DELETE /lockers405 with Allow: GETRoute exists, method does not; tell the client what is allowed
Reserve an occupied locker409Request is valid but conflicts with current state
Body over the limit413Refuse before buffering unbounded data

Shutting down and testing

The SIGINT handler (Ctrl+C) calls server.close(), which stops accepting connections and calls back once existing ones finish, and closeIdleConnections() drops keep-alive sockets that would otherwise hold the process open. On Linux deployments the equivalent signal is usually SIGTERM; handle both. For tests, start the server with listen(0) so the operating system picks a free port, read server.address().port, and call it with the global fetch. Node also has a built-in test runner, node --test, so you need no extra dependency to do this.

Common mistakes and the symptoms they produce

  • Forgetting `res.end()` on one branch. Symptom: curl hangs until timeout for that one route while others work. Fix: make every path end with a send call, and return after it.
  • Writing headers after the body has started. Symptom: ERR_HTTP_HEADERS_SENT - Cannot set headers after they are sent to the client. This happens when both an if branch and the fall-through code send a response.
  • Using `string.length` for `content-length`. Symptom: a body with ₹250 is 4 characters but 6 bytes, so clients truncate or hang. Use Buffer.byteLength.
  • Not catching errors in an async handler. Symptom: one malformed request becomes an unhandled rejection and kills the whole process, as in the event loop lesson. The try/catch around route prevents it.
  • Trusting the body. Symptom: Cannot destructure property 'parcelId' of null when a client sends null, or silently storing a number where a string was expected. Validate the type of every field you read.
  • Reading the whole body with no limit. Symptom: one huge upload exhausts memory. The size check in readJson is the minimum defence.

How this shows up in real projects and interviews

Most production Node services use a framework such as Express or Fastify for routing, validation and middleware, and that is the sensible choice once you have more than a few routes: you get tested parsing, schema validation and ecosystem plugins. Plain node:http still earns its place for health-check sidecars, small internal tools, webhook receivers, and for understanding what the frameworks do under the hood. Interviewers ask "how would you build a server without Express?" to see whether you know that the handler gets streams, that you must validate input, and that the status code is part of the contract. To design that contract well, continue with REST API Development and the Node.js overview.

Try it yourself

Extend the locker service with GET /lockers/:id that returns one locker (404 if unknown) and a DELETE /lockers/:id/reservation route that releases a locker regardless of parcel and returns 204 with no body. Then add a request log line (method, path, status, duration in ms) that prints when each response finishes. Test every branch with curl, including a wrong method on the new routes.

Show a hint

Listen for the finish event on res and measure time from a performance.now() taken at the start of the handler. For 204, call res.writeHead(204) then res.end() with no payload and no content-length of a body.

Key takeaways

  • http.createServer calls your handler per request with req (a readable stream) and res (a writable stream); always finish with res.end().
  • Centralise JSON sending, body reading and error handling in small helpers so every route behaves consistently.
  • Pick status codes deliberately: 400 for unusable input, 404 for missing, 405 with Allow, 409 for state conflicts, 413 for oversized bodies.
  • Set content-length with Buffer.byteLength and limit how much body you are willing to buffer.
  • Shut down gracefully, and test with listen(0), fetch and node --test instead of fixed ports.

Test yourself

A trick from this lesson's ride: Every path through a handler must end in res.end(), or that client waits forever. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

How do I create a simple HTTP server in Node.js without Express?

Import createServer from node:http, pass it a function (req, res) => { ... } that writes a status, headers and body and calls res.end(), then call server.listen(port). Routing is just comparing req.method and the pathname from new URL(req.url, base), and the request body is read by iterating over req as an async iterable.

How do I read a JSON request body in Node.js http?

The body is a stream, so collect its chunks, join them with Buffer.concat, decode as UTF-8 and call JSON.parse inside a try/catch. Enforce a maximum size while reading so one client cannot exhaust memory, and return 400 for invalid JSON and 413 for oversized bodies.

Should I use Express or the built-in http module?

Use a framework like Express or Fastify for anything beyond a handful of routes, because routing, validation and middleware are already solved and tested. The built-in module is a good fit for tiny services, health checks and learning, and everything you learn with it carries over, since frameworks are built on the same request and response objects.