The http Module

Node.js · Lesson 12 of 30 7 min read Updated

Every Node.js web framework, including Express, is built on one built-in module: node:http. Before you use Express, it is worth building a small server by hand. You will see what a request and a response really are, and later you will understand exactly what Express does for you.

In this lesson you will start a server, send HTML and JSON, handle different URLs and methods, read data that a client sends, set status codes and headers, and call your server with the built-in fetch().

How HTTP works in one minute

HTTP is the language browsers and servers use to talk. A client (a browser, a mobile app, another server) sends a request, and the server sends back one response.

  • A request has a method (what to do: GET to read, POST to create, PUT/PATCH to change, DELETE to remove), a URL path such as /menu, headers (extra information such as the content type) and sometimes a body (the data sent, for example a form or JSON).
  • A response has a status code (200 OK, 201 Created, 400 Bad Request, 404 Not Found, 500 Server Error), headers, and a body: HTML, JSON, an image and so on.

Your first server

JavaScript
// hello-server.js
import http from "node:http";

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  res.end("Namaste from Node.js!");
});

server.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

Run it with node hello-server.js and open http://localhost:3000 in your browser. localhost means "this computer", and 3000 is the port, a number that identifies which program on the computer should receive the connection.

createServer() takes a function that runs for every request. It receives two objects:

  • req (the request) is a readable stream with properties such as req.method, req.url and req.headers.
  • res (the response) is a writable stream. writeHead() sets the status code and headers, and end() sends the body and finishes the response.

The program keeps running because the server is waiting for connections. Stop it with Ctrl+C. If you start it twice, the second copy fails with EADDRINUSE, which means "address already in use": another program already has port 3000.

Routes, JSON and request bodies

A real API answers different URLs and methods differently. Choosing the code to run for a method and path is called routing. Here is a small menu API for a tea stall:

JavaScript
// server.js
import http from "node:http";

const menu = [
  { id: 1, name: "Masala chai", price: 20 },
  { id: 2, name: "Samosa", price: 15 },
];

function sendJson(res, status, data) {
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(data));
}

async function readJsonBody(req) {
  let body = "";
  for await (const chunk of req) {
    body += chunk;
    if (body.length > 10_000) {
      throw Object.assign(new Error("Body too large"), { status: 413 });
    }
  }
  return JSON.parse(body);
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  console.log(`${req.method} ${url.pathname}`);

  try {
    if (req.method === "GET" && url.pathname === "/") {
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      res.end("<h1>Welcome to Chai Point, Pune</h1>");
    } else if (req.method === "GET" && url.pathname === "/menu") {
      const max = Number(url.searchParams.get("max") ?? Infinity);
      sendJson(res, 200, menu.filter((item) => item.price <= max));
    } else if (req.method === "POST" && url.pathname === "/menu") {
      const item = await readJsonBody(req);
      const newItem = { id: menu.length + 1, name: item.name, price: item.price };
      menu.push(newItem);
      sendJson(res, 201, newItem);
    } else {
      sendJson(res, 404, { error: "Not found" });
    }
  } catch (err) {
    if (err instanceof SyntaxError) {
      sendJson(res, 400, { error: "Body must be valid JSON" });
    } else {
      sendJson(res, err.status ?? 500, { error: err.status ? err.message : "Server error" });
    }
  }
});

server.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

The important parts:

  • req.url holds only the path and query, such as /menu?max=18. Combining it with the Host header in new URL() lets you use pathname and searchParams, which you learned in path & URL Modules.
  • The request body arrives as a stream of chunks, because it could be large. for await collects it. The size check stops someone from sending a huge body to fill your memory; 413 means "Content Too Large".
  • JSON.stringify() turns the data into text for the response, and the Content-Type header tells the client it is JSON.
  • try...catch turns bad JSON into a 400 response instead of crashing the server. Unknown errors send a vague 500 message so internal details do not leak to users.

Calling your server with fetch

Node.js has the same global fetch() function as the browser, so you can test your API from another file. Keep the server running in one terminal and run this in a second terminal:

JavaScript
// client.js
let res = await fetch("http://localhost:3000/menu?max=18");
console.log(res.status, res.headers.get("content-type"));
console.log(await res.json());

res = await fetch("http://localhost:3000/menu", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Vada pav", price: 25 }),
});
console.log(res.status, await res.json());

res = await fetch("http://localhost:3000/orders");
console.log(res.status, await res.json());

res = await fetch("http://localhost:3000/menu", { method: "POST", body: "not json" });
console.log(res.status, await res.json());
Output
200 application/json; charset=utf-8
[ { id: 2, name: 'Samosa', price: 15 } ]
201 { id: 3, name: 'Vada pav', price: 25 }
404 { error: 'Not found' }
400 { error: 'Body must be valid JSON' }

Meanwhile, the server terminal logs each request:

Output
Server running at http://localhost:3000
GET /menu
POST /menu
GET /orders
POST /menu

You can also test from the terminal with curl, which is included in Windows 10 and 11, macOS and most Linux systems. The -i option shows the status line and headers:

Terminal
curl -i http://localhost:3000/
Output
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Date: Fri, 30 Oct 2026 09:52:20 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

<h1>Welcome to Chai Point, Pune</h1>

Node.js added Date, Connection and the other headers itself.

Why use a framework?

The server above works, but look at what you had to write by hand: a long if...else chain for routes, body collecting and parsing, size limits, JSON helpers and error handling. Add login checks, file uploads, static files and fifty routes, and the code becomes hard to manage. Express, which you will learn next, does all of this with a few lines while still using node:http underneath. Knowing the raw module helps you debug: an Express req and res are the same objects with extra methods added.

Common mistakes

  • Never calling res.end(). The browser keeps loading until it times out. Every request must get exactly one response.
  • Sending two responses. Calling res.end() twice, or writing headers after the body started, throws ERR_HTTP_HEADERS_SENT. Use if...else or return after sending.
  • Trusting the body. Always handle invalid JSON and limit the body size.
  • Forgetting Content-Type. Without application/json, clients may not treat your data as JSON.
  • Port already in use. EADDRINUSE means another program, often an old copy of your server, is using the port. Stop it or choose another port.

Key points

  • http.createServer(handler) runs handler(req, res) for every request; server.listen(port) starts it.
  • req has method, url, headers and a body you read as a stream.
  • res.writeHead(status, headers) sets the status and headers; res.end(body) sends the response.
  • Parse req.url with new URL(), send JSON with JSON.stringify() and the right Content-Type.
  • Handle errors and limit body size so bad requests cannot crash the server.
  • Node.js has a built-in fetch() for calling servers and APIs.

Exercise

Write a server on port 4000 for a cricket club. GET /players returns a JSON array of three players (name and runs). GET /players/top returns only the player with the most runs. Any other path returns status 404 with { "error": "Not found" }. Test all three with fetch() or curl.

Show solution
JavaScript
// club.js
import http from "node:http";

const players = [
  { name: "Rohan", runs: 412 },
  { name: "Farhan", runs: 538 },
  { name: "Aarav", runs: 297 },
];

function sendJson(res, status, data) {
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(data));
}

http.createServer((req, res) => {
  const { pathname } = new URL(req.url, "http://localhost");

  if (req.method === "GET" && pathname === "/players") {
    sendJson(res, 200, players);
  } else if (req.method === "GET" && pathname === "/players/top") {
    const top = players.reduce((best, p) => (p.runs > best.runs ? p : best));
    sendJson(res, 200, top);
  } else {
    sendJson(res, 404, { error: "Not found" });
  }
}).listen(4000, () => console.log("Club API on http://localhost:4000"));
Terminal
curl http://localhost:4000/players/top
Output
{"name":"Farhan","runs":538}

reduce() keeps whichever player has more runs. Because each branch sends exactly one response, there is no risk of ERR_HTTP_HEADERS_SENT.

Quick Quiz

1. What does res.end() do?

2. Which status code means a new item was created?

3. Why is the request body read with for await (const chunk of req)?

4. What does the error EADDRINUSE mean when you start a server?

Learn Node.js with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call