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.

Beginner

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 this

res 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?