The http Module
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:
GETto read,POSTto create,PUT/PATCHto change,DELETEto 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 (
200OK,201Created,400Bad Request,404Not Found,500Server Error), headers, and a body: HTML, JSON, an image and so on.
Your first server
// 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 asreq.method,req.urlandreq.headers.res(the response) is a writable stream.writeHead()sets the status code and headers, andend()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:
// 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.urlholds only the path and query, such as/menu?max=18. Combining it with theHostheader innew URL()lets you usepathnameandsearchParams, which you learned in path & URL Modules.- The request body arrives as a stream of chunks, because it could be large.
for awaitcollects it. The size check stops someone from sending a huge body to fill your memory;413means "Content Too Large". JSON.stringify()turns the data into text for the response, and theContent-Typeheader tells the client it is JSON.try...catchturns bad JSON into a400response instead of crashing the server. Unknown errors send a vague500message 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:
// 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());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:
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:
curl -i http://localhost:3000/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, throwsERR_HTTP_HEADERS_SENT. Useif...elseorreturnafter sending. - Trusting the body. Always handle invalid JSON and limit the body size.
- Forgetting
Content-Type. Withoutapplication/json, clients may not treat your data as JSON. - Port already in use.
EADDRINUSEmeans another program, often an old copy of your server, is using the port. Stop it or choose another port.
Key points
http.createServer(handler)runshandler(req, res)for every request;server.listen(port)starts it.reqhasmethod,url,headersand a body you read as a stream.res.writeHead(status, headers)sets the status and headers;res.end(body)sends the response.- Parse
req.urlwithnew URL(), send JSON withJSON.stringify()and the rightContent-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
// 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"));curl http://localhost:4000/players/top{"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?
Every request must end with exactly one res.end(), which finishes the response.
2. Which status code means a new item was created?
201 Created is the usual response to a successful POST that creates something.
3. Why is the request body read with for await (const chunk of req)?
req is a stream; you collect the chunks, then parse them.
4. What does the error EADDRINUSE mean when you start a server?
Only one program can listen on a port; stop the other copy or change the port.
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
