How does the HTTP module work in Node.js?
http is a built-in Node.js module that lets you create HTTP servers and make outbound HTTP requests, with no npm install required.
Theory
TL;DR
- Every HTTP server in Node runs in a single process but handles many connections through the event loop
reqis anIncomingMessage(readable stream),resis aServerResponse(writable stream)- The request body does not arrive all at once. You collect chunks via
dataevents http.createServer()returns anet.Serverunder the hood. Express wraps this same function- Use
httpsfor production (TLS) andhttp2when you need multiplexing
Quick example
const http = require('http');
const server = http.createServer((req, res) => {
// req = IncomingMessage (readable stream)
// res = ServerResponse (writable stream)
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Hello' }));
});
server.listen(3000, () => console.log('Running on port 3000'));The callback fires on every incoming request. res.end() flushes the response and signals that you are done writing.
How the request-response cycle works
When a client connects, Node's TCP layer accepts the connection and starts parsing the HTTP message. Once the headers are parsed, Node fires your createServer callback with req and res. The body has not been fully read yet. It is still streaming in. That is why you listen for data and end events manually.
res.writeHead() writes the status line and headers. res.end() sends the body and closes the response. Forgetting res.end() is one of those bugs you hit once when a curl request just hangs and you spend ten minutes wondering why.
Reading the request object
const server = http.createServer((req, res) => {
console.log(req.method); // 'GET', 'POST', etc.
console.log(req.url); // '/api/users?page=2'
console.log(req.headers); // { host: 'localhost:3000', ... }
const url = new URL(req.url, `http://${req.headers.host}`);
console.log(url.pathname); // '/api/users'
console.log(url.searchParams.get('page')); // '2'
res.end('ok');
});req.url is a raw string. Use the built-in URL constructor to parse query parameters reliably. String splits break the moment a query string appears.
Collecting a POST body
The body arrives in chunks. You assemble them yourself:
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/api/users') {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
const user = JSON.parse(body);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ created: user }));
});
}
});There is no body size limit by default. A client can stream gigabytes at you. In production, always cap body size. Express does this automatically through bodyParser.
Making outbound requests
const http = require('http');
// GET
http.get('http://api.example.com/data', (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => console.log(JSON.parse(data)));
});
// POST
const payload = JSON.stringify({ name: 'Alice' });
const req = http.request(
{
hostname: 'api.example.com',
port: 80,
path: '/users',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => console.log(JSON.parse(data)));
}
);
req.write(payload);
req.end();The callback in http.request() is shorthand for the response event. Without req.end() the request never sends.
http vs https vs http2
| Module | Protocol | When to use |
|---|---|---|
http | HTTP/1.1 | Local dev, internal services on a private network |
https | HTTPS + TLS | Any production server that handles real users |
http2 | HTTP/2 | When you need multiplexing or server push |
https requires TLS certificates. http2 uses a different API (http2.createSecureServer) and supports multiplexed streams over a single TCP connection.
http vs Express
| Feature | Raw http | Express.js |
|---|---|---|
| Routing | Manual if/else on req.url | app.get('/path', handler) |
| Body parsing | Manual stream collection | express.json() middleware |
| Middleware | Not built in | Supported out of the box |
| Static files | Manual | express.static() |
Express calls http.createServer() internally. When you write app.listen(3000), Express runs http.createServer(app).listen(3000) behind the scenes. You are always using the http module. Express just gives you a better API on top.
Common mistakes
1. Forgetting res.end()
// Broken - client hangs indefinitely
http.createServer((req, res) => {
res.writeHead(200);
// res.end() is missing
});The response stream never closes. The client waits until timeout.
2. Writing headers after the body has started
// Broken
http.createServer((req, res) => {
res.write('some data');
res.writeHead(200); // Error: Cannot set headers after they are sent
});res.writeHead() must come before any res.write() call.
3. No error handler on req
// Fragile - process will crash if client disconnects early
req.on('data', (chunk) => { body += chunk; });
// Correct
req.on('data', (chunk) => { body += chunk; });
req.on('error', (err) => {
res.writeHead(400);
res.end(err.message);
});If the client disconnects mid-request, Node emits an error event on req. Without a handler, the process crashes.
4. No body size limit
let body = '';
req.on('data', (chunk) => {
body += chunk;
if (body.length > 1e6) { // 1MB cap
req.destroy();
res.writeHead(413);
res.end('Payload too large');
}
});5. Parsing req.url with string splits
// Breaks with query strings like /users/42?format=json
const id = req.url.split('/')[2];
// Reliable
const url = new URL(req.url, `http://${req.headers.host}`);
const id = url.pathname.split('/')[2];Where this appears in real codebases
- Express and Fastify both call
http.createServer()under the hood - Next.js custom servers use it directly
- Lightweight Node microservices that skip full frameworks
http.get()andhttp.request()appear in older code written beforefetchbecame available in Node 18
Follow-up questions
Q: Why does Express use http.createServer() instead of its own TCP layer?
A: Because http is Node's built-in TCP parser and HTTP message handler. Every Node HTTP framework has to go through it. Express adds routing, middleware chains, and better request/response helpers on top of the same callback.
Q: What is IncomingMessage and why does it extend a readable stream?
A: IncomingMessage represents the incoming HTTP message. It extends stream.Readable because the body can be arbitrarily large. Reading it as a stream avoids loading the whole payload into memory at once.
Q: How does Node handle concurrent requests if it is single-threaded?
A: The event loop handles I/O asynchronously. While one request waits for a database query, the loop picks up other incoming connections. No threads are needed for I/O-bound work.
Q: What happens if you call res.end() twice?
A: Node throws Error: write after end. The second call tries to write to an already-closed stream.
Q: How would you protect an http server from clients sending oversized bodies?
A: Set a size limit inside the data handler and call req.destroy() when the threshold is exceeded. Also set server.timeout to close idle connections automatically.
Examples
Basic JSON API endpoint
const http = require('http');
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/api/users') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(users));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
});
server.listen(3000);A GET to /api/users returns the user list. Everything else gets a 404. This is the same pattern Express routes use, without the abstraction layer.
Creating a user with POST body handling
const http = require('http');
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/api/users') {
res.writeHead(404);
res.end();
return;
}
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
if (body.length > 1e5) { // 100KB guard
req.destroy();
}
});
req.on('end', () => {
try {
const user = JSON.parse(body);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: Date.now(), ...user }));
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
req.on('error', () => {
res.writeHead(400);
res.end();
});
});
server.listen(3000);This is the full pattern you would need in production: a body size guard, JSON parse error handling, and an error listener on the request stream.
Proxying a request to an internal service
const http = require('http');
const server = http.createServer((req, res) => {
const proxy = http.request(
{
hostname: 'internal-service',
port: 8080,
path: req.url,
method: req.method,
headers: req.headers,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res); // stream response directly back to the client
}
);
req.pipe(proxy); // stream request body to the internal service
proxy.on('error', () => {
res.writeHead(502);
res.end('Bad gateway');
});
});
server.listen(3000);Piping req directly into the proxy request avoids buffering the body in memory. The response streams back the same way. This is the pattern behind HTTP reverse proxies.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.