Building an HTTP server from scratch with the http module
Every framework's server (Express included) is ultimately built on top of Node's own http module — seeing what that module actually provides, with nothing hidden behind a framework, is what makes clear exactly what a framework like Express is adding on top.
3 min read
The minimal, complete HTTP server — no framework at all
const http = require("node:http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello from a raw Node server");
});
server.listen(3000, () => console.log("Listening on port 3000"));http.createServer() takes a callback that runs once per incoming request, receiving a request object (req, readable-stream-based, covered in the previous lesson) and a response object (res, writable-stream-based) — genuinely everything an HTTP server needs to exist, with no external dependency at all. This is the actual foundation every Node web framework, including Express, is built on top of: understanding what's here directly explains what a framework is adding (and what it's abstracting away) rather than treating app.get(...) as unexplainable magic.
req: the incoming request, as a readable stream plus metadata
const server = http.createServer((req, res) => {
console.log(req.method); // "GET", "POST", etc.
console.log(req.url); // "/api/users?active=true" — the raw path AND query string, unparsed
console.log(req.headers); // { "content-type": "application/json", ... }
let body = "";
req.on("data", (chunk) => { body += chunk; }); // req IS a readable stream — chunks arrive over time
req.on("end", () => { console.log("full body:", body); }); // fires once all chunks have arrived
});req.url is genuinely raw and unparsed — no automatic query-string parsing, no route-parameter extraction, nothing Express-style — which is exactly the kind of convenience a framework provides on top of this. Reading the request body requires listening to data events as chunks arrive (since req is a readable stream, the exact mechanism the streams lesson covered) and accumulating them manually until the end event fires — there's no req.body handed to you automatically at this raw level.
Manual routing: string-matching req.url and req.method by hand
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/users") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: 1, name: "Ada" }]));
} else if (req.method === "POST" && req.url === "/users") {
// ... handle creating a user
} else {
res.writeHead(404);
res.end("Not found");
}
});With no framework, "routing" is just manually checking req.method and req.url against expected values, branch by branch — no wildcard/parameter matching (/users/:id), no automatic 404 for unmatched routes, none of it built in. This is real, working code — genuinely how Node servers were commonly written before Express became close to universal — and seeing it directly is what makes clear that Express's routing (the next lesson) isn't a fundamentally different mechanism, just a much more convenient, declarative way of expressing the exact same method + url matching this code does by hand.
res: a writable stream, and the response is unbuffered by default
res.writeHead(200, { "Content-Type": "text/plain" }); // sets status code + headers — must happen BEFORE writing the body
res.write("first chunk of the response... "); // res IS a writable stream — write() sends data immediately
res.write("second chunk...");
res.end("final chunk, and marks the response as COMPLETE"); // end() closes the response — nothing can be written after thisres is a writable stream — res.write() sends data to the client as it's called, not buffered and sent all at once at the end, and res.end() marks the response as finished, after which no more writes are allowed. writeHead() (setting the status code and headers) has to happen before any write() calls, since HTTP headers are sent first, followed by the body — attempting to set headers after writing body data throws a real, specific error, because those headers have already physically been sent to the client by that point.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What does every Node web framework, including Express, ultimately build on top of?
2. Why does reading a request body with the raw http module require listening to 'data' and 'end' events, rather than just accessing `req.body`?
3. Why must `res.writeHead()` be called before any `res.write()` calls?