Creating a Basic HTTP Server
Every Node.js web framework you will ever use — Express, Fastify, NestJS, the server half of Next.js — is built on one built-in module: http. Spending a lesson with it directly is worth far more than it costs, because afterwards you can tell which parts of Express are genuine convenience and which parts are simply the HTTP protocol wearing a friendlier name.
http.createServer() takes a single function and hands back a server object. That function runs once for every request that arrives, with two arguments. req carries everything the client sent: the URL, the method, the headers, and for a POST or PUT, the body. res is the answer you are assembling — a status code, some headers, a body. Nothing leaves your machine until you write to res, and the browser does not consider the response finished until you call res.end().
Creating the server does not start it. server.listen(port) does, by asking the operating system to reserve that port number and hand your process every connection arriving on it. This is also why a Node server keeps running where a plain script would have exited: an open listening socket counts as outstanding work, so the event loop never runs out of things to wait for. Ctrl+C is what ends it.
Your handler runs on the same single thread as the rest of the program, which makes the rule from lesson 1 concrete. Two browser tabs hitting your server are not two threads; they are two calls to the same function, one after the other, on the same thread. Whatever slow synchronous work sits inside that function, every other visitor waits through it.
The mistake to expect first is a forgotten res.end(). Node does not warn you, nothing appears in the terminal, and the request simply hangs until the browser gives up half a minute later. Whenever a page loads forever with no error anywhere, look for a code path — usually an if branch added later — that never reaches res.end().
const http = require('http');
const server = http.createServer((req, res) => {
// Set the response headers
res.writeHead(200, { 'Content-Type': 'text/html' });
// Send the response body
res.end('<h1>Hello from Node.js Server!</h1>');
});
// Start listening on port 3000
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
// Visit http://localhost:3000 in your browser http.createServer(handler)— build a server; the handler runs once per requestreq.url,req.method,req.headers— what the client actually asked forres.writeHead(status, headers)— set the status code and headers, before any bodyres.write(chunk)— send part of the body; you may call it many timesres.end(body)— send the last piece and close the response; never optionalserver.listen(port, callback)— reserve the port and start accepting connectionsserver.close()— stop accepting new connections, used when shutting down cleanly
- Run this file and your terminal will not return to the prompt. That is correct, not a hang — the process is holding a port open. Leave it running in one terminal and use a second one for
curl, and remember that editing the file changes nothing until you restart the process or start it withnode --watch.
Routing by Hand: req.url and req.method
The http module has no routing. What it has is a string on req.url, another string on req.method, and whatever if chain you are willing to write. That is worth seeing once, because app.get('/about', handler) in Express is this same comparison with better ergonomics — not a different mechanism.
Always match the method as well as the path. /api/books means "list the books" for GET and "create a book" for POST, and a branch that checks only the URL runs the same code for both. In an API that is not a style mistake; it is a route that creates a record when somebody meant to read one.
req.url is not the path. It is the raw request target, query string included, so a request to /search?q=node gives you the string '/search?q=node' and your === '/search' comparison quietly fails. Every beginner meets this on the day they add their first filter. Build a URL object from it, as the example does, and you get a clean pathname plus a decoded searchParams for free.
The else at the end is not optional. Without it, any request matching nothing reaches the end of your handler without a response, and that connection hangs until it times out — including the automatic /favicon.ico request every browser sends, which is why a first hand-written server often seems mysteriously slow.
Notice how much is missing. There is no way to capture /users/42 without splitting the string yourself, no request body, no cookies, no way to run one check before every route. Add ten routes and this function is a hundred lines of branching. That is the honest reason Express exists, and having written this by hand once is why the next lesson will feel like a relief rather than magic.
const http = require('node:http');
const server = http.createServer((req, res) => {
// req.url is the RAW request target and includes the query string:
// '/search?q=node' is NOT equal to '/search'. Parse it, do not compare it.
const { pathname, searchParams } = new URL(req.url, `http://${req.headers.host}`);
console.log(req.method, pathname);
if (pathname === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>Home Page</h1><a href="/about">About</a>');
} else if (pathname === '/about' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>About Page</h1><a href="/">Home</a>');
} else if (pathname === '/api/data' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Hello from API',
q: searchParams.get('q') || null
}));
} else {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>404 - Page Not Found</h1>');
}
});
server.listen(3000, () => {
console.log('Server running on http://localhost:3000');
}); req.method—'GET','POST'and so on, always uppercasereq.url— the raw target including the query string, never just the pathnew URL(req.url, ...)— gives you a cleanpathnameand decodedsearchParamsreq.headers— a plain object with every header name lowercased- Match method and path together, or one URL quietly serves two different intentions
- Always finish with a fallback branch that sends a 404, or unmatched requests hang
- The status code is not decoration. A browser, a search crawler and a frontend
fetch()all change behaviour based on it: 200 means "use this", 301 means "remember the new address", 404 means "do not retry", 500 means "something broke, try again later". Answering 200 with a body that says{ "error": "not found" }is a common and genuinely damaging bug, because every automated client believes the code and ignores your prose.
The Request Body Arrives in Pieces
req does not hand you the body. A GET request usually has none, but for POST and PUT the body arrives as a stream: the client sends it in chunks across the network, and Node gives you each chunk as it lands rather than holding everything back until the last byte.
That is why the example listens for two events. 'data' fires once per chunk, 'end' fires when the client has finished sending. You gather the chunks and join them at the end. Collecting Buffer objects and calling Buffer.concat is deliberate: building a string with body += chunk looks simpler and then corrupts any multi-byte character — a name written in Devanagari, an emoji — that happens to be split across a chunk boundary.
Parsing is the next trap. JSON.parse throws on anything malformed, and a throw inside the 'end' callback is not caught by a try/catch wrapped around the code that registered that callback. By the time it runs, the surrounding function has long since returned and its catch no longer exists. The try/catch has to sit inside the callback, and this is the first place where the difference between synchronous and asynchronous error handling really bites.
Body size is a security question rather than a tidiness one. Nothing written so far stops a client from streaming you a two-gigabyte body, and because you are accumulating it in memory, one request is enough to exhaust the server. Count the bytes as they arrive, answer with 413 past a sensible limit, and destroy the connection instead of politely reading the rest of it.
Add a check that the Content-Type header really says application/json, and you have written express.json(). That is honestly all it is: chunk collection, a size limit, a content-type check and a guarded parse. Knowing that is what turns a middleware from a magic word into a tool you can reason about.
const http = require('node:http');
const MAX_BODY = 1_000_000; // 1 MB — refuse anything larger
const server = http.createServer((req, res) => {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'Method not allowed' }));
}
const chunks = [];
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY) {
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Body too large' }));
req.destroy(); // stop reading; do not keep buffering
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (res.writableEnded) return; // we already answered with 413
// The try/catch MUST live in here. An outer one would never see this throw.
try {
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: body }));
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
req.on('error', () => res.destroy());
});
server.listen(3000); - Concatenating chunks into a string works in every test you will write, because your test bodies are small and English. It fails in production on the first long name, address or comment in an Indian language, and the symptom is a stray replacement character in the middle of a word — a bug that is very hard to trace back to this line.
One Request, One Response
HTTP allows exactly one response per request, and Node enforces it. Attempt a second and you get Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client. It is probably the most-searched error in Node web development, and although people meet it in Express, it belongs to this layer.
The cause is almost always a missing return. You write a guard clause that answers with 401 and then carry on to the normal path, because sending a response does not stop the function it was called from. res.end(...) is an ordinary function call, not a jump out of the handler. So the guard replies, execution continues to the next line, and the next line replies again.
The asynchronous shape of this bug is worse, because it usually crashes twice. A handler checks whether a user exists, sends a 404 without returning, then awaits something using the user object it just proved was missing. You now get a TypeError on null as well as the headers-sent error, and the stack trace points at the second line rather than the missing return above it.
Ordering matters too. Headers must go before any part of the body. Once a single byte has been written with res.write(), the header block has already travelled to the client, so res.writeHead() or res.setHeader() after that point throws for exactly the same underlying reason.
The habit that makes the whole class of bug disappear is small: write return in front of every response, including the one on the last line of the handler where it changes nothing. Being unconditional about it means you never have to decide, and a reviewer can see correctness at a glance.
const http = require('node:http');
function send(res, status, payload) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
}
// WRONG — the guard replies, then execution simply continues
const broken = (req, res) => {
if (!req.headers.authorization) {
send(res, 401, { error: 'Login required' }); // no return!
}
send(res, 200, { ok: true });
// Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent
};
// RIGHT — return every time you respond
const fixed = (req, res) => {
if (!req.headers.authorization) {
return send(res, 401, { error: 'Login required' });
}
return send(res, 200, { ok: true });
};
// Same bug, async shape — and it crashes twice
const brokenAsync = async (req, res) => {
const user = await findUser(req);
if (!user) send(res, 404, { error: 'Not found' }); // no return!
const orders = await findOrders(user.id); // TypeError: user is null
send(res, 200, orders); // ...then HEADERS_SENT
};
// Headers must go BEFORE any body byte:
// res.write('partial');
// res.writeHead(200); // throws — the header block has already gone ERR_HTTP_HEADERS_SENT— you replied twice; hunt for a guard clause with noreturn- Sending a response is a function call, not a jump — the rest of the handler still runs
- Write
return res.end(...)unconditionally, even on the final line - Set every header before the first
res.write() - In async handlers the same bug usually adds a
TypeErrorthat hides the real cause
- Because this error comes from Node's
httplayer rather than from Express, rearranging middleware never fixes it. The fix is always a missingreturn— or, occasionally, a callback that fires twice. Read the handler from the top and find the first line that could reply before the one that crashed.
Ports, Hosts, and What a Framework Buys You
Hard-coding 3000 is fine while you are learning and wrong the moment you deploy. Nearly every hosting platform chooses which port your process should listen on and passes it in the environment as PORT. A server that ignores process.env.PORT starts up perfectly, logs nothing unusual, and is then marked unhealthy because nothing is listening where the platform expected. One line covers both situations: const PORT = process.env.PORT || 3000;
EADDRINUSE is the other port error, and it means something already holds that number. Nine times out of ten it is an older copy of your own server, still alive in a terminal tab you closed without pressing Ctrl+C. It also appears if you call listen twice in the same program, which is easy to do after splitting startup across two modules.
Which address you bind to starts to matter once your code leaves your laptop. listen(3000) accepts connections on every network interface the machine has; listen(3000, '127.0.0.1') accepts them only from the same machine. The restricted form is a sensible safety measure when a reverse proxy is meant to be the only thing talking to you, and a baffling outage when your process runs inside a container and the request comes from outside it. If a containerised app is unreachable but its logs say it started, check the bind address first.
None of what Express adds replaces this module. It gives you route parameters, query and body parsing, static file serving, an ordered middleware chain, a proper error path and small helpers such as res.status().json(). Underneath, app.listen() hands back an ordinary http.Server — the same object you built by hand here — which is exactly why you can pass an Express app to http.createServer(app) when a WebSocket library needs to share the port.
Write one raw server by hand, once. The rest of this course uses Express, but every step it takes — reading req.method, matching a path, collecting a body, choosing a status code, calling end — is what you have just done manually. That is the difference between framework errors that are readable and framework errors that are mystifying.
const http = require('node:http');
const PORT = process.env.PORT || 3000; // platform first, 3000 as a local fallback
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
server.listen(PORT, () => {
console.log(`Listening on http://localhost:${PORT}`);
});
// A clearer message than a raw stack trace when the port is taken
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${PORT} is already in use — stop the other process or set PORT.`);
process.exit(1);
}
throw err;
});
// Stop cleanly instead of cutting live requests off mid-response
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
});
// Express, when you get there, is the same server underneath:
// const app = express();
// const server = app.listen(PORT); // an http.Server, exactly like the one above process.env.PORT || 3000— read the platform's port, keep a local defaultEADDRINUSE— an old process still holds the port, orlistenwas called twice- Binding to
127.0.0.1blocks access from outside the machine, containers included app.listen()in Express returns a plainhttp.Server— nothing is hidden from you- Express adds routing, parameters, body parsing, static files, middleware and error handling
- Never write the port number into your code and separately into a deployment setting. One source of truth — the environment variable, with a local fallback — is the entire point, and a mismatch between the two produces a deployment failure that looks convincingly like a networking problem.
