Express, Fastify, Koa — they’re all wrappers around this. Node ships with everything needed to build an HTTP server and client out of the box. Understanding the raw http module is what separates “I use a framework” from “I know what my framework actually does.”
In simple language: http.createServer gives us a callback (req, res) => {...} that fires for every incoming request. req is a readable stream (the request), res is a writable stream (the response we send back). That’s the whole API.
A minimal server
import http from 'node:http';
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ hello: 'world' }));
});
server.listen(3000, () => {
console.log('Listening on http://localhost:3000');
});
No dependencies. No framework. Real production HTTP server.
The req / res lifecycle
IncomingMessage — the request
req is a readable stream. The body doesn’t arrive in one chunk — we have to assemble it.
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/echo') {
const body = await readBody(req);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(body);
} else {
res.writeHead(404);
res.end();
}
});
This is exactly what Express’s body-parser does for us, just hidden behind req.body.
ServerResponse — sending back
Three layers of writing:
res.writeHead(statusCode, headers)— sends the status line + headers. Call once.res.write(chunk)— sends a body chunk. Call zero or more times (streaming).res.end([chunk])— finishes the response. Required, else the client hangs forever.
We can stream a big response without buffering:
import fs from 'node:fs';
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'video/mp4' });
fs.createReadStream('./big.mp4').pipe(res);
}).listen(3000);
pipe connects the file stream to the response stream — chunks flow through, memory stays flat.
Raw http vs Express — what does Express add?
Almost everything in Express is sugar over what we just wrote:
| What Express adds | Underlying http |
|---|---|
Routing (app.get('/users/:id', ...)) | Manual req.url + req.method checks |
req.body, req.params, req.query | Manual stream reading and URL parsing |
| Middleware chain | One handler function |
res.json(), res.send() | writeHead + end |
| Error handling middleware | Try/catch + sending error responses |
Express isn’t magic — it’s a thoughtful set of patterns on top of http.createServer. Knowing this means we can drop down to raw http for performance-critical endpoints, or build our own framework in a weekend.
http.request — the client side
Same module, opposite direction. We can make outgoing HTTP requests too.
import http from 'node:http';
const req = http.request({
hostname: 'api.example.com',
path: '/users/42',
method: 'GET',
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
console.log('Got:', Buffer.concat(chunks).toString('utf8'));
});
});
req.on('error', console.error);
req.end(); // sends the request
In practice, we use the built-in fetch (Node 18+) for this — it’s promise-based and matches the browser API. But http.request is what powers libraries like axios and is still the most efficient option for streaming or fine-grained control over keep-alive and agents.
The keep-alive gotcha
By default, Node creates a new TCP connection for every outgoing request. For high-volume calls (one service calling another thousands of times a minute), this is brutal. We use an Agent with keepAlive: true to reuse connections:
import { Agent } from 'node:http';
const agent = new Agent({ keepAlive: true, maxSockets: 50 });
// pass agent into http.request options
Modern fetch and clients like undici do this automatically.
The mental model
http.createServer takes (req, res). req is a stream we read. res is a stream we write. Everything else — routing, middleware, JSON parsing — is a pattern built on top. Once that clicks, no Node HTTP code is mysterious anymore.