Skip to main content

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
  • req is an IncomingMessage (readable stream), res is a ServerResponse (writable stream)
  • The request body does not arrive all at once. You collect chunks via data events
  • http.createServer() returns a net.Server under the hood. Express wraps this same function
  • Use https for production (TLS) and http2 when you need multiplexing

Quick example

js
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

js
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:

js
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

js
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

ModuleProtocolWhen to use
httpHTTP/1.1Local dev, internal services on a private network
httpsHTTPS + TLSAny production server that handles real users
http2HTTP/2When 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

FeatureRaw httpExpress.js
RoutingManual if/else on req.urlapp.get('/path', handler)
Body parsingManual stream collectionexpress.json() middleware
MiddlewareNot built inSupported out of the box
Static filesManualexpress.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()

js
// 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

js
// 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

js
// 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

js
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

js
// 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() and http.request() appear in older code written before fetch became 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

js
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

js
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

js
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?