http module

intermediate nodejs http server networking

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

Client
sends request
req (IncomingMessage)
method, url, headers, body stream
handler runs
res.writeHead → res.write → res.end
Client receives

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 addsUnderlying http
Routing (app.get('/users/:id', ...))Manual req.url + req.method checks
req.body, req.params, req.queryManual stream reading and URL parsing
Middleware chainOne handler function
res.json(), res.send()writeHead + end
Error handling middlewareTry/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.