Node.js · lesson 2 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
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
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.