Creating an HTTP Server with the http Module
Node.js ships with a built-in http module that lets you create a fully functional web server without any third-party dependencies. While frameworks like Express add convenience, understanding the raw http module teaches you what every Node web server is actually doing under the hood: accepting connections, reading incoming requests, and writing responses byte by byte. This page walks through creating a server, handling the request and response objects, setting status codes and headers, and even building a small router by hand.
Creating a server
The entry point is http.createServer(). It accepts a request listener — a callback invoked once per incoming HTTP request — and returns a Server instance. The listener receives two arguments: an IncomingMessage (the request, conventionally req) and a ServerResponse (the response, conventionally res).
import http from 'node:http';
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('Hello from Node.js!\n');
});
server.listen(3000, () => {
console.log('Server running at http://localhost:3000/');
});
Run the file and hit it with curl:
node server.js
curl -i http://localhost:3000/
Output:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Sun, 14 Jun 2026 10:00:00 GMT
Connection: keep-alive
Content-Length: 20
Hello from Node.js!
If you prefer CommonJS, swap the import for
const http = require('node:http');. Everything else stays identical — thenode:prefix works in both module systems and guarantees you load the core module rather than a same-named package fromnode_modules.
Inspecting the request
The req object is a readable stream that also carries useful metadata. The most common properties are req.method, req.url, and req.headers. Note that req.url contains only the path and query string — never the protocol or host — so for full URL parsing you construct a URL against a base.
import http from 'node:http';
const server = http.createServer((req, res) => {
const { pathname, searchParams } = new URL(req.url, `http://${req.headers.host}`);
console.log(`${req.method} ${pathname}`);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ method: req.method, pathname, name: searchParams.get('name') }));
});
server.listen(3000);
A request to http://localhost:3000/greet?name=Ada logs and returns:
Output:
GET /greet
{"method":"GET","pathname":"/greet","name":"Ada"}
Status codes and headers
You can set the response status and headers two ways. Setting res.statusCode and calling res.setHeader() individually is convenient when values are computed across several lines. For a single atomic call, res.writeHead(statusCode, headers) sets everything at once. Either way, headers must be sent before any body data — once the first chunk is written, the headers are flushed and become immutable.
const server = http.createServer((req, res) => {
res.writeHead(201, {
'Content-Type': 'application/json',
'X-Powered-By': 'node-http',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ created: true }));
});
| Member | Purpose | Notes |
|---|---|---|
res.statusCode | Set the numeric status | Defaults to 200 |
res.setHeader(name, value) | Set a single header | Must run before the body |
res.writeHead(code, headers) | Set status + headers together | Overrides earlier setHeader calls for the same names |
res.write(chunk) | Stream a body chunk | Can be called repeatedly |
res.end([chunk]) | Finish the response | Required — the request hangs without it |
Always call
res.end(). If you forget, the client keeps waiting until it times out because the server never signals the response is complete.
Building a router by hand
The single request listener handles every route, so routing is just branching on req.method and the pathname. A clean pattern is a lookup table keyed by "METHOD path", with a fallback for anything unmatched.
import http from 'node:http';
function sendJson(res, status, payload) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
}
const routes = {
'GET /': (req, res) => sendJson(res, 200, { message: 'Welcome' }),
'GET /health': (req, res) => sendJson(res, 200, { status: 'ok' }),
'GET /users': (req, res) => sendJson(res, 200, [{ id: 1, name: 'Ada' }]),
};
const server = http.createServer((req, res) => {
const { pathname } = new URL(req.url, `http://${req.headers.host}`);
const handler = routes[`${req.method} ${pathname}`];
if (handler) {
handler(req, res);
} else {
sendJson(res, 404, { error: 'Not Found' });
}
});
server.listen(3000, () => console.log('Listening on :3000'));
Test a known and an unknown route:
curl -s http://localhost:3000/health
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/missing
Output:
{"status":"ok"}
404
This scales surprisingly far. Once you need path parameters (/users/:id), wildcard matching, or middleware, that’s the moment a framework starts paying for itself — but the mechanics are exactly the branching you wrote above.
Listening and shutting down
server.listen() binds to a port and starts accepting connections; it is asynchronous, so the callback fires once the socket is ready. You can also listen on a specific host, a Unix socket path, or let the OS pick a free port by passing 0. For clean shutdowns, call server.close() to stop accepting new connections while letting in-flight requests finish.
const server = http.createServer(handler);
server.listen(3000, '127.0.0.1');
process.on('SIGTERM', () => {
server.close(() => {
console.log('Server closed, exiting.');
process.exit(0);
});
});
Best Practices
- Use the
node:httpprefix so you always load the core module, never a shadowing package. - Parse
req.urlwith theURLclass instead of string splitting — it handles query strings and encoding correctly. - Set headers before writing the body, and always finish responses with
res.end(). - Send an explicit
Content-Typeso clients interpret the body correctly; default toapplication/jsonfor APIs. - Return a real
404(and405for wrong methods) for unmatched routes rather than letting requests hang. - Handle
SIGTERM/SIGINTwithserver.close()for graceful shutdown in containers and process managers. - Reach for a framework only once hand-rolled routing, body parsing, and error handling become repetitive.