Skip to main content
Node.js · lesson 2 of 20
Node.js beginner Lesson 2 of 20

The HTTP Module and Why Frameworks Exist

Build a server with Node's built-in http module, route by hand with regex, and parse a POST body yourself, to see exactly what Express replaces.

By SkillByExample · Published

nodejs http

Node’s http module can run a whole API with no dependencies. Every output below is Node 26.12.1 (Docker’s node:26 image, node --version prints v26.12.1), run from /root/node-api.

A Server With Two Routes, By Hand

createServer’s callback receives the raw request and response for every request, with no routing built in.

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

const users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
];

const server = createServer((req, res) => {
  console.log(`${req.method} ${req.url}`);

  if (req.method === "GET" && req.url === "/users") {
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify(users));
    return;
  }

  const match = req.url.match(/^\/users\/(\d+)$/);
  if (req.method === "GET" && match) {
    const user = users.find((u) => u.id === Number(match[1]));
    if (!user) {
      res.writeHead(404, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "not found" }));
      return;
    }
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify(user));
    return;
  }

  res.writeHead(404, { "content-type": "application/json" });
  res.end(JSON.stringify({ error: "no route" }));
});

server.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node server.js &
$ curl -s http://localhost:3000/users
[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]
$ curl -s http://localhost:3000/users/2
{"id":2,"name":"Bob"}
$ curl -s http://localhost:3000/users/99
{"error":"not found"}
$ curl -s -X POST http://localhost:3000/users
{"error":"no route"}

Server-side, the same requests log as:

listening on http://localhost:3000
GET /users
GET /users/2
GET /users/99
POST /users

There’s no router object — just an if chain testing req.method and matching req.url against a regex to pull out the :id segment manually. POST /users falls through every branch to the final 404, because nothing handles that method yet. This already shows the shape of the problem: every new route means one more if, and every route repeats the same writeHead/JSON.stringify/end dance.

A Request Body Is a Stream, Not a Value

Unlike a GET request, a POST body doesn’t exist up front — it arrives as data events on the request object, and you decide when it’s finished.

// server-post.js
import { createServer } from "node:http";

const users = [{ id: 1, name: "Alice" }];

function readBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
    req.on("error", reject);
  });
}

const server = createServer(async (req, res) => {
  if (req.method === "POST" && req.url === "/users") {
    const raw = await readBody(req);
    let data;
    try {
      data = JSON.parse(raw);
    } catch {
      res.writeHead(400, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "invalid json" }));
      return;
    }
    const user = { id: users.length + 1, name: data.name };
    users.push(user);
    res.writeHead(201, { "content-type": "application/json" });
    res.end(JSON.stringify(user));
    return;
  }

  res.writeHead(404, { "content-type": "application/json" });
  res.end(JSON.stringify({ error: "no route" }));
});

server.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node server-post.js &
$ curl -s -i -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Carol"}'
HTTP/1.1 201 Created
content-type: application/json
Date: Wed, 28 Oct 2026 09:08:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"id":2,"name":"Carol"}
$ curl -s -i -X POST http://localhost:3000/users -H 'content-type: application/json' -d 'not-json'
HTTP/1.1 400 Bad Request
content-type: application/json
Date: Wed, 28 Oct 2026 09:08:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"error":"invalid json"}

readBody has to buffer every chunk before JSON.parse can run at all — there’s no req.body to read. This data/end/error dance, the manual try/catch around JSON.parse, and the regex router from the first example are exactly what Express’s express.json() middleware and Router exist to replace. The next lesson installs Express and rewrites both examples in a fraction of the code.

Practice

1. Add a DELETE /users/:id route to server.js that removes a user and returns 204 with no body, or 404 if the id doesn’t exist.

Solution
// practice1.js
import { createServer } from "node:http";

let users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
];

const server = createServer((req, res) => {
  const match = req.url.match(/^\/users\/(\d+)$/);
  if (req.method === "DELETE" && match) {
    const id = Number(match[1]);
    const existed = users.some((u) => u.id === id);
    users = users.filter((u) => u.id !== id);
    res.writeHead(existed ? 204 : 404);
    res.end();
    return;
  }
  res.writeHead(404, { "content-type": "application/json" });
  res.end(JSON.stringify({ error: "no route" }));
});

server.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node practice1.js &
$ curl -s -i -X DELETE http://localhost:3000/users/1
HTTP/1.1 204 No Content
Date: Wed, 28 Oct 2026 14:20:15 GMT
Connection: keep-alive
Keep-Alive: timeout=5

$ curl -s -i -X DELETE http://localhost:3000/users/1
HTTP/1.1 404 Not Found
Date: Wed, 28 Oct 2026 14:20:16 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

The second delete 404s because users.some no longer finds id 1 — it was already removed by the first request.

2. Change readBody in server-post.js to reject with an error if the body exceeds 1000 bytes, instead of buffering an unbounded body.

Solution
// practice2.js
import { createServer } from "node:http";

function readBody(req, limit = 1000) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let size = 0;
    req.on("data", (chunk) => {
      size += chunk.length;
      if (size > limit) {
        reject(new Error("payload too large"));
        return;
      }
      chunks.push(chunk);
    });
    req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
    req.on("error", reject);
  });
}

const server = createServer(async (req, res) => {
  if (req.method === "POST" && req.url === "/users") {
    try {
      const raw = await readBody(req);
      res.writeHead(201, { "content-type": "application/json" });
      res.end(JSON.stringify({ receivedBytes: raw.length }));
    } catch (err) {
      res.writeHead(413, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: err.message }));
    }
    return;
  }
  res.writeHead(404);
  res.end();
});

server.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node practice2.js &
$ curl -s -i -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"small"}'
HTTP/1.1 201 Created
content-type: application/json
Date: Wed, 28 Oct 2026 09:39:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"receivedBytes":16}
$ head -c 2000 /dev/zero | tr '\0' 'x' > big.txt
$ curl -s -i -X POST http://localhost:3000/users --data-binary @big.txt
HTTP/1.1 413 Payload Too Large
content-type: application/json
Date: Wed, 28 Oct 2026 09:40:07 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"error":"payload too large"}

Once size crosses limit, every later chunk still fires the same data listener and re-checks size, but nothing is pushed to chunks anymore, so memory stays bounded. Without this check, a client that keeps streaming data events forever would grow chunks without limit — this is the manual version of the limit option a body-parsing middleware gives you for free.

3. Predict what curl -s http://localhost:3000/users/abc returns against server.js, then run it.

Solution
$ curl -s http://localhost:3000/users/abc
{"error":"no route"}

/^\/users\/(\d+)$/ only matches digits, so /users/abc never matches the second if and falls through to the final 404 — the same body as a request to a route that was never defined at all, which is a little misleading: a malformed id and a nonexistent route look identical here.

4. Add an empty-body check: if a POST to /users has no body at all (raw === ""), respond 400 with {"error": "empty body"} before attempting JSON.parse.

Solution
// practice4.js
import { createServer } from "node:http";

const users = [{ id: 1, name: "Alice" }];

function readBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
    req.on("error", reject);
  });
}

const server = createServer(async (req, res) => {
  if (req.method === "POST" && req.url === "/users") {
    const raw = await readBody(req);
    if (raw === "") {
      res.writeHead(400, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "empty body" }));
      return;
    }
    let data;
    try {
      data = JSON.parse(raw);
    } catch {
      res.writeHead(400, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "invalid json" }));
      return;
    }
    const user = { id: users.length + 1, name: data.name };
    users.push(user);
    res.writeHead(201, { "content-type": "application/json" });
    res.end(JSON.stringify(user));
    return;
  }

  res.writeHead(404, { "content-type": "application/json" });
  res.end(JSON.stringify({ error: "no route" }));
});

server.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node practice4.js &
$ curl -s -i -X POST http://localhost:3000/users
HTTP/1.1 400 Bad Request
content-type: application/json
Date: Wed, 28 Oct 2026 14:20:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"error":"empty body"}

Without this check, JSON.parse("") throws, and the request would have landed in the generic invalid json branch anyway — this just gives an empty body its own clearer message.

Frequently Asked Questions

Do I need Express to write an HTTP server in Node.js?
No. Node's built-in http module can create a server, inspect the request method and URL, and write a response. Express adds routing, JSON body parsing, and middleware on top of that module — it doesn't replace it.
Why doesn't req.body exist on the raw http.IncomingMessage?
The request body arrives as a stream of Buffer chunks over time, not as a single parsed value. Node hands you the stream and lets you decide how to buffer and parse it, which is why a GET-only server never needs to think about it but a POST handler must collect every chunk before parsing.
What does res.writeHead do that res.end doesn't?
writeHead sends the status code and headers; end sends the response body (and ends the response if headers weren't already sent). Calling writeHead is optional — if you skip it, Node sends a default 200 with your end() call.
Is routing with regex on req.url a real pattern or just a teaching example?
It's real, and it's exactly what early Node servers and some microservices still do for a handful of routes. It stops scaling once you have more than a few routes with parameters, which is the specific gap Express's Router fills.
What Node.js version does this lesson use?
Node 26.12.1, run in Docker's node:26 image. Every command and output shown was captured from that exact setup.