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 req and res — the body may not have fully arrived yet.
    • Your handler registers data, end, and error listeners on req and returns immediately; the event loop is now free to service other connections.
    • As more body bytes arrive over the socket, each chunk triggers a data event asynchronously.
    • Once the client finishes sending the body, Node emits end on req, and your callback there runs, parses the JSON, and calls res.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 matching res.end() on every code path, including error branches.
    • Set Content-Type explicitly — http never guesses it for you.
    • Use fs/promises or streams instead of the *Sync fs functions inside any request handler.
    • Prefer pipeline() from node:stream/promises over manual .pipe() when streaming a file or proxying a response — it handles errors and cleanup for you.
    • Attach an error listener to req whenever you consume its stream manually.
    • Read the port from the environment with process.env.PORT ?? 3000 so 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 /time that responds with the current ISO timestamp as JSON, GET /health that responds {"status":"ok"}, and a catch-all that responds 404 with a JSON error body.
    • Extend the Example 3 /echo server so it rejects bodies larger than 1 MB while they’re still streaming in, instead of waiting for end and rejecting afterward. (Hint: track the running byte total inside the data handler and call req.destroy() if it’s exceeded.)
    • Write a server that streams a local JSON file to the client using createReadStream and pipeline(), and manually test what happens to the connection if you kill curl mid-download.

    Summary

    • node:http is the low-level module that frameworks like Express are built on top of; createServer(handler) runs your callback once per request.
    • req is a readable stream (the request body arrives as data/end events); res is a writable stream you fill with write() and must finish with end().
    • There is no built-in router — you branch on req.method and req.url yourself.
    • Never use synchronous fs calls inside a handler; prefer fs/promises or streams with pipeline() for backpressure-safe file transfer.
    • Always attach an error listener to streams you consume, and make sure every branch of your handler eventually calls res.end().