The Node.js HTTP Module
Every web framework you’ll ever use in Node.js — Express, Fastify, Koa — is built on top of one small core module: node:http. It gives you direct access to the TCP-based HTTP protocol: you get a raw incoming request, and you are responsible for writing a valid response yourself. Understanding http matters even if you never use it directly in production, because it explains what those frameworks are doing for you under the hood, and it’s the fastest way to build a tiny server, a health-check endpoint, or a proxy without installing anything.
In this lesson you’ll build real HTTP servers with nothing but core Node.js: routing requests, sending JSON, reading a request body, and streaming a file — plus the mistakes that trip up almost everyone the first time they write a server by hand.
Overview / How It Works
Node’s http module wraps a TCP server. When you call http.createServer(handler), Node opens a socket server and, for every new connection, uses libuv (Node’s underlying C library) to watch that socket for incoming data without blocking the main thread. When bytes arrive, Node’s HTTP parser (written in C++ for speed) parses the raw bytes into a request line, headers, and body, and only then does your JavaScript handler function run — once per request, with two objects: req (an http.IncomingMessage) and res (an http.ServerResponse).
Both req and res are streams. req is a readable stream — the request body (if any) arrives as a sequence of Buffer chunks over time, not as one big string handed to you up front. res is a writable stream — you call res.write() as many times as you like and finish with res.end(). This streaming design is why Node can handle thousands of concurrent connections on a single thread: it never has to buffer a whole request or response in memory unless you explicitly choose to.
Because handling a request is asynchronous and event-driven, your handler function returns almost immediately after calling res.end() is scheduled, and the event loop moves on to service other connections. There’s no thread-per-request the way there is in some other runtimes — a single thread juggles many in-flight requests by reacting to I/O events (“a new chunk of the body arrived”, “the socket is ready to accept more written bytes”) as libuv reports them.
Syntax
import { createServer } from "node:http";
const server = createServer((req, res) => {
// req: IncomingMessage (readable stream)
// res: ServerResponse (writable stream)
});
server.listen(port, () => {
// called once the server is accepting connections
});
| Piece | What it is |
|---|---|
createServer(handler) |
Creates an http.Server; handler runs once per request |
req.method |
HTTP verb as a string: "GET", "POST", "PUT", "DELETE", etc. |
req.url |
Path and query string requested, e.g. "/users?active=true" |
req.headers |
Plain object of lower-cased request headers |
res.writeHead(status, headers) |
Sets the status code and response headers (must be called before writing the body) |
res.write(chunk) |
Sends a piece of the response body; can be called multiple times |
res.end([chunk]) |
Finishes the response; every request must eventually call this |
server.listen(port, cb) |
Starts accepting connections on the given port |
Examples
Example 1: A minimal server
import { createServer } from "node:http";
const server = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello from Node.js!\n");
});
server.listen(3000, () => {
console.log("Server running at http://localhost:3000/");
});
Server running at http://localhost:3000/
# a GET request to / responds with: Hello from Node.js!
This is the whole shape of every Node HTTP server: a handler that inspects req and writes to res. Notice writeHead comes before end — headers cannot be changed once the body starts sending.
Example 2: Routing by method and URL
import { createServer } from "node:http";
const users = [
{ id: 1, name: "Ada Lovelace" },
{ id: 2, name: "Grace Hopper" },
];
const server = createServer((req, res) => {
const { method, url } = req;
if (method === "GET" && url === "/users") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(users));
return;
}
if (method === "GET" && url === "/") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Welcome to the API\n");
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
server.listen(3000, () => {
console.log("Listening on port 3000");
});
curl http://localhost:3000/users
# [{"id":1,"name":"Ada Lovelace"},{"id":2,"name":"Grace Hopper"}]
There is no built-in router in http — you compare req.method and req.url yourself. This is exactly the gap frameworks like Express fill; here you can see there’s nothing magic underneath it.
Example 3: Reading a request body
The request body isn’t available as a property — you must consume it as a stream, because Node doesn’t know how large it is or whether you even want it.
import { createServer } from "node:http";
const server = createServer((req, res) => {
if (req.method === "POST" && req.url === "/echo") {
const chunks = [];
req.on("data", (chunk) => {
chunks.push(chunk);
});
req.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
let parsed;
try {
parsed = JSON.parse(body);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ youSent: parsed }));
});
req.on("error", (err) => {
console.error("Request stream error:", err.message);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
}
res.end(JSON.stringify({ error: "Server error" }));
});
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
server.listen(3000);
curl -X POST -d '{"hello":"world"}' -H "Content-Type: application/json" http://localhost:3000/echo
# {"youSent":{"hello":"world"}}
Every data event delivers one Buffer chunk; you collect them and only parse once end fires, meaning the whole body has arrived. The error listener matters — a client that disconnects mid-upload emits an error on req, and without a listener Node will throw and can crash the process.
Example 4: Streaming a file response
import { createServer } from "node:http";
import { createReadStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { join } from "node:path";
const filePath = join(import.meta.dirname, "report.txt");
const server = createServer(async (req, res) => {
if (req.method === "GET" && req.url === "/download") {
res.writeHead(200, {
"Content-Type": "text/plain",
"Content-Disposition": "attachment; filename=report.txt",
});
try {
await pipeline(createReadStream(filePath), res);
} catch (err) {
console.error("Stream failed:", err.message);
if (!res.headersSent) {
res.writeHead(500);
}
res.end("Failed to stream file");
}
return;
}
res.writeHead(404);
res.end("Not found");
});
server.listen(3000);
Instead of reading the whole file into memory and writing it in one go, pipeline() pumps the file in chunks straight into the response stream and automatically pauses reading whenever res‘s internal buffer fills up — this is backpressure. It also forwards errors from either side and cleans up the file handle if the client disconnects early, which a manual .pipe() chain does not do reliably. Note the file path is built with import.meta.dirname (Node 20.11+), the ESM replacement for the CommonJS-only __dirname.
How It Works Step by Step
For a POST /echo request like Example 3, the order of operations is:
- A TCP connection arrives; libuv notifies Node’s event loop that the socket is readable.
- Node’s HTTP parser reads enough bytes to build the request line and headers, then invokes your handler synchronously with
reqandres— the body may not have fully arrived yet. - Your handler registers
data,end, anderrorlisteners onreqand returns immediately; the event loop is now free to service other connections. - As more body bytes arrive over the socket, each chunk triggers a
dataevent asynchronously. - Once the client finishes sending the body, Node emits
endonreq, and your callback there runs, parses the JSON, and callsres.end(). - Calling
res.end()flushes any buffered bytes and tells Node the response is complete; the socket is then kept alive for the next request (HTTP keep-alive) or closed.
Common Mistakes
Mistake 1: Forgetting to call res.end()
If you write to res but never call end(), the client hangs forever waiting for the response to finish — the connection never completes.
const server = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.write("Hello");
// res.end() is never called — the response never finishes
});
Every code path in your handler must reach a res.end() call, including error branches:
const server = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.write("Hello");
res.end();
});
Mistake 2: Blocking the event loop with synchronous I/O
Node is single-threaded for your JavaScript. A synchronous file read inside a request handler blocks every concurrent connection until it finishes — fine for a one-off startup script, disastrous inside a server.
import { readFileSync } from "node:fs";
const server = createServer((req, res) => {
const data = readFileSync("./big-file.json", "utf8");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(data);
});
Use the promise-based API so the read happens off the main thread’s blocking path (via libuv’s thread pool) while the event loop keeps serving other requests:
import { readFile } from "node:fs/promises";
const server = createServer(async (req, res) => {
try {
const data = await readFile("./big-file.json", "utf8");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(data);
} catch (err) {
console.error(err);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Could not read file" }));
}
});
Other frequent mistakes: forgetting the error listener on req (an uncaught stream error can crash the process), calling res.writeHead() twice for the same response (throws), and reading an entire large upload into memory with an array of chunks instead of streaming it to disk when the payload could be large.
Best Practices
- Always pair a
writeHead/status with a matchingres.end()on every code path, including error branches. - Set
Content-Typeexplicitly —httpnever guesses it for you. - Use
fs/promisesor streams instead of the*Syncfsfunctions inside any request handler. - Prefer
pipeline()fromnode:stream/promisesover manual.pipe()when streaming a file or proxying a response — it handles errors and cleanup for you. - Attach an
errorlistener toreqwhenever you consume its stream manually. - Read the port from the environment with
process.env.PORT ?? 3000so the same code works locally and in production. - For anything beyond a handful of routes, reach for a framework (Express, Fastify) — they’re built on this exact module and add routing, middleware, and body parsing for you.
Practice Exercises
- Build a server with three routes:
GET /timethat responds with the current ISO timestamp as JSON,GET /healththat responds{"status":"ok"}, and a catch-all that responds404with a JSON error body. - Extend the Example 3
/echoserver so it rejects bodies larger than 1 MB while they’re still streaming in, instead of waiting forendand rejecting afterward. (Hint: track the running byte total inside thedatahandler and callreq.destroy()if it’s exceeded.) - Write a server that streams a local JSON file to the client using
createReadStreamandpipeline(), and manually test what happens to the connection if you killcurlmid-download.
Summary
node:httpis the low-level module that frameworks like Express are built on top of;createServer(handler)runs your callback once per request.reqis a readable stream (the request body arrives asdata/endevents);resis a writable stream you fill withwrite()and must finish withend().- There is no built-in router — you branch on
req.methodandreq.urlyourself. - Never use synchronous
fscalls inside a handler; preferfs/promisesor streams withpipeline()for backpressure-safe file transfer. - Always attach an
errorlistener to streams you consume, and make sure every branch of your handler eventually callsres.end().
