C.W.K.
Stream
Lesson 02 of 05 · published

The http Module — HTTP From Scratch

~14 min · io-net, http, server

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Frameworks hide the http module. Reading the http module makes you not need the framework — or at least understand what it's doing on your behalf."

A Server in 10 Lines

import { createServer } from 'node:http';

const server = createServer((req, res) => {
  // req is an http.IncomingMessage (a Readable stream)
  // res is an http.ServerResponse (a Writable stream)
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end(`hi, you asked for ${req.url}\n`);
});

server.listen(3000, () => {
  console.log('http://localhost:3000');
});

That's a complete, production-shaped HTTP server. No Express, no Koa, no Fastify. Every framework you've ever used is a layer on top of this exact API. The handler runs for every request; req and res are streams; listen() binds to a port.

The Anatomy of a Request

req (IncomingMessage) carries:

  • req.method — 'GET', 'POST', etc.
  • req.url — the path + query string, e.g. /api/users?id=5.
  • req.headers — an object of lowercased header names to values.
  • The body is a Readable stream — consume with for await (const chunk of req).

The Anatomy of a Response

res (ServerResponse) lets you:

  • res.writeHead(statusCode, headersObject) — set status and headers in one call.
  • res.setHeader(name, value) — set a single header.
  • res.write(chunk) — write to the body. Multiple calls allowed.
  • res.end([chunk]) — finalize and close. Required.

Forgetting res.end() means the client hangs forever. The framework you usually use wraps this contract and ensures end is called; the http module does not police you.

Reading a Request Body

The body of a POST/PUT request arrives as a stream of chunks. Modern Node makes this nearly painless:
import { createServer } from 'node:http';
import { json } from 'node:stream/consumers';

createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/api/users') {
    try {
      const body = await json(req);     // consume + parse in one helper
      console.log('got user:', body.name);
      res.writeHead(201, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ id: 42, ...body }));
    } catch (e) {
      res.writeHead(400);
      res.end('bad json');
    }
  }
}).listen(3000);
The node:stream/consumers module (Node 16+) provides text(), json(), buffer(), arrayBuffer(), blob() — each fully consumes a stream into the named shape. Replaces the manual chunk-accumulation dance that every Node tutorial used to teach.

Outbound HTTP — The Old Way

The http module also has a client side via http.request / http.get. In 2026 nobody uses these for HTTP clients — fetch (next lesson) is the universal answer. The exception is when you need precise control over the underlying socket: keep-alive policies, custom DNS resolution, raw stream access. For 99% of outbound HTTP, fetch is right.

HTTPS — Same API, Different Module

node:https is structurally identical to node:http but adds TLS. createServer(options, handler) takes { key, cert } for the server's certificate. Most production deployments terminate TLS at a reverse proxy (nginx, Cloudflare, the cloud load balancer) and serve plain http internally — fewer cert-rotation headaches, easier process restarts. Decide where TLS lives in your stack on day one.

Pippa's Confession

I built cwkPippa's first backend on Express because every tutorial said to. Then I read FastAPI's docs (Python sibling), which is similarly framework-on-runtime, and Dad pointed out: the actual server lifecycle is identical — bind port, accept request, write response, end. The framework's value is routing + middleware + body parsing helpers. None of it is mandatory. Knowing the http module makes you fluent in every Node framework, because you can see what each one is bolting onto the same foundation.

Code

10-line router — when you don't need a framework·javascript
// A tiny routing layer — no framework needed
import { createServer } from 'node:http';
import { json } from 'node:stream/consumers';

const routes = new Map();
routes.set('GET /', () => ({ status: 200, body: 'home' }));
routes.set('GET /health', () => ({ status: 200, body: 'ok' }));
routes.set('POST /echo', async (req) => ({
  status: 200,
  body: await json(req),
}));

const server = createServer(async (req, res) => {
  const key = `${req.method} ${req.url.split('?')[0]}`;
  const handler = routes.get(key);
  if (!handler) {
    res.writeHead(404).end('not found');
    return;
  }
  try {
    const { status, body } = await handler(req);
    const type = typeof body === 'string' ? 'text/plain' : 'application/json';
    const text = typeof body === 'string' ? body : JSON.stringify(body);
    res.writeHead(status, { 'Content-Type': type });
    res.end(text);
  } catch (e) {
    res.writeHead(500).end(e.message);
  }
});
server.listen(3000);
Graceful shutdown — what every production server needs·javascript
// Graceful shutdown — critical for production
import { createServer } from 'node:http';

const server = createServer((req, res) => {
  res.end('hi');
});
server.listen(3000);

// Stop accepting new connections, finish in-flight ones, then exit
let shuttingDown = false;
for (const sig of ['SIGINT', 'SIGTERM']) {
  process.on(sig, () => {
    if (shuttingDown) return;
    shuttingDown = true;
    console.log('shutting down...');
    server.close((err) => {
      if (err) console.error(err);
      process.exit(err ? 1 : 0);
    });
    // Force-exit after 10s if connections won't drain
    setTimeout(() => {
      console.warn('force-exit after 10s');
      process.exit(1);
    }, 10_000).unref();
  });
}

External links

Exercise

Build a tiny HTTP server with three routes — GET / returns 'hi', POST /echo returns the JSON body as-is, GET /slow sleeps 2 seconds and returns 'done'. Add a graceful shutdown on SIGINT that finishes the in-flight /slow request before exiting. Test it: start the server, curl /slow, while it's running, Ctrl-C. The slow request should complete; the next curl should fail-connect.
Hint
server.close() stops accepting new connections but waits for active ones. Combined with the SIGINT handler, you get graceful shutdown. The 10-second force-exit safety is for the case where a connection is hung (keepalive holding it open) — without it, your server takes forever to die. .unref() on the timeout means it doesn't keep the process alive itself.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.