<< Home

Chapter 14: Networking and HTTP

Networking is one of Node’s core strengths. This chapter focuses on building and tuning networked programs with the standard library. You will learn the differences between http, https, and http2; how to create a minimal server; how to add WebSockets for real time features; and how to improve throughput with keep alive and caching.

💡 When experimenting locally, keep terminal logs readable. Prefix messages with the connection’s remote address or a short request id for easier tracing.

http, http2, and https modules

Node provides three related server side modules. The http module implements HTTP 1.1 over TCP. The https module is the same API layered over TLS. The http2 module adds HTTP 2 features such as multiplexing and header compression, which can reduce latency under concurrent load.

Module Protocol Key capabilities Typical use
http HTTP/1.1 Request per connection or pipelining; simple API; ubiquitous tooling Internal services; simple APIs; dev servers
https HTTP/1.1 + TLS TLS termination; certificates; same surface as http Public endpoints; auth flows; anything crossing networks you do not control
http2 HTTP/2 Multiplexed streams; HPACK compression; server push… High concurrency; resource heavy pages; microservices behind a proxy

Creating a basic http server

The http.createServer API handles a request object and a response object. You write headers and a body, then end the response. The following example is TypeScript friendly.

import http from "node:http";

const server = http.createServer((req, res) => {
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.writeHead(200);
  res.end(JSON.stringify({ ok: true, path: req.url }));
});

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

Upgrading to https

Switch to https and provide key material. Use strong ciphers and modern TLS versions. In production, many teams terminate TLS at a reverse proxy, although you can also do it in Node.

import https from "node:https";
import fs from "node:fs";

const options = {
  key: fs.readFileSync("./certs/server.key"),
  cert: fs.readFileSync("./certs/server.crt"),
  // ca: fs.readFileSync("./certs/ca.crt") // for client auth …
};

const server = https.createServer(options, (req, res) => {
  res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
  res.end("secure hello");
});

server.listen(3443, () => {
  console.log("https server on https://localhost:3443");
});
⚠️ Self signed certificates are fine for local development. Browsers will warn. For real deployments use a certificate from a trusted authority.

Multiplexing

The http2 module exposes different request and response objects named streams. Each stream is an independent bidirectional channel over one TCP connection. Browsers only allow secure HTTP 2, so use TLS for public sites.

import http2 from "node:http2";
import fs from "node:fs";

const server = http2.createSecureServer({
  key: fs.readFileSync("./certs/server.key"),
  cert: fs.readFileSync("./certs/server.crt")
});

server.on("stream", (stream, headers) => {
  // Each stream handles one request
  stream.respond({ ":status": 200, "content-type": "text/plain; charset=utf-8" });
  stream.end("hello over http/2");
});

server.listen(3444, () => {
  console.log("http2 server on https://localhost:3444");
});

Building a minimal server

A minimal server reads the method and path, routes to a handler, and returns a structured result. Start small, keep the surface simple, then add middleware style helpers as needed.

A tiny router

This example shows a compact approach that parses JSON and routes by method and url. It is easy to extend with more paths and helpers.

import http, { IncomingMessage, ServerResponse } from "node:http";

type Handler = (req: IncomingMessage, res: ServerResponse) => void;

const routes = new Map<string, Handler>();

routes.set("GET /", (req, res) => {
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
  res.end("<h1>Home</h1>");
});

routes.set("POST /echo", async (req, res) => {
  const chunks: Buffer[] = [];
  for await (const c of req) chunks.push(c as Buffer);
  const body = Buffer.concat(chunks).toString("utf8");
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify({ youSent: body }));
});

const server = http.createServer((req, res) => {
  const key = `${req.method} ${req.url}`;
  const handler = routes.get(key);
  if (handler) return handler(req, res);
  res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify({ error: "not found" }));
});

server.listen(3001, () => console.log("listening on http://localhost:3001"));
💡 Add a small helper for JSON replies to remove duplication. For example, wrap res.writeHead and res.end so your handlers return plain objects.

Making outbound requests

Modern Node exposes a global fetch which is convenient for high level requests. For fine control or streaming, use http.request or https.request.

// High level
const r = await fetch("https://api.example.com/data");
if (!r.ok) throw new Error(`http ${r.status}`);
const data = await r.json();
console.log(data);

// Low level with streaming
import https from "node:https";

const req = https.request("https://api.example.com/stream", res => {
  res.on("data", chunk => process.stdout.write(chunk));
});
req.on("error", err => console.error(err));
req.end();

WebSockets and event driven patterns

WebSockets provide full duplex messaging over a single connection. You can add WebSockets to an existing HTTP server and share the same port. The usual pattern is event driven: listen for connections, subscribe to message events, and broadcast updates to peers.

Server sidee

The ws library integrates cleanly with a Node server. It handles the WebSocket handshake during the HTTP upgrade step and then exposes a simple message event API.

import http from "node:http";
import { WebSocketServer } from "ws";

const httpServer = http.createServer();
const wss = new WebSocketServer({ server: httpServer });

wss.on("connection", socket => {
  console.log("client connected");
  socket.send(JSON.stringify({ hello: "world" }));
  socket.on("message", msg => {
    // Broadcast to everyone
    for (const client of wss.clients) {
      if (client.readyState === 1) client.send(msg);
    }
  });
});

httpServer.listen(3080, () => console.log("ws on ws://localhost:3080"));
⚠️ If you run behind a proxy or load balancer, ensure TCP upgrade headers are forwarded. Configure sticky sessions when your broadcast depends on process local state.

Browser client and heartbeat

Keep connections healthy with a lightweight heartbeat. Send periodic pings or app messages. Close idle sockets to reclaim resources gracefully.

// Browser code
const ws = new WebSocket("ws://localhost:3080");
ws.addEventListener("open", () => ws.send(JSON.stringify({ type: "hello" })));
ws.addEventListener("message", ev => console.log("msg:", ev.data));
// heartbeat
setInterval(() => {
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
}, 10000);

Performance management

Small improvements can multiply under load. Keep connections open when useful, compress responses, and use caching headers. Profile with realistic concurrency and payloads.

Client side keep alive

Use a shared agent with pooling and timeouts. This reduces TCP handshakes and can improve throughput significantly.

import https from "node:https";

const agent = new https.Agent({
  keepAlive: true,
  maxSockets: 100,
  timeout: 30_000
});

export async function getJson(url: string) {
  const r = await fetch(url, { dispatcher: agent as any });
  if (!r.ok) throw new Error(`http ${r.status}`);
  return r.json();
}

Server side keep alive

Enable keep alive on responses and compress textual data. Many proxies handle compression, although you can also do it in Node.

import http from "node:http";
import { createGzip } from "node:zlib";
import { pipeline } from "node:stream";

const server = http.createServer((req, res) => {
  res.setHeader("Connection", "keep-alive");
  const accept = req.headers["accept-encoding"] ?? "";
  const payload = Buffer.from("lots of text …");

  if (accept.includes("gzip")) {
    res.writeHead(200, { "content-encoding": "gzip", "content-type": "text/plain; charset=utf-8" });
    pipeline(ReadableFromBuffer(payload), createGzip(), res, err => err && console.error(err));
  } else {
    res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
    res.end(payload);
  }
});

function ReadableFromBuffer(buf: Buffer) {
  return new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(buf);
      controller.close();
    }
  }) as any;
}

server.listen(3002);
💡 Prefer HTTP 2 for many small resources. Multiplexing avoids head of line blocking that can occur with several HTTP 1.1 requests over limited connections.

HTTP caching

Caching cuts latency and reduces server load. Use strong validators when content changes and explicit lifetimes when it does not.

Header Purpose Example
Cache-Control Freshness lifetime and rules Cache-Control: public, max-age=300, stale-while-revalidate=30
ETag Content validator for conditional requests ETag: "sha256-…"
Last-Modified Timestamp validator alternative Last-Modified: Tue, 01 Oct 2025 10:00:00 GMT
import http from "node:http";
import crypto from "node:crypto";

const body = Buffer.from(JSON.stringify({ message: "cache me" }));
const etag = `"${crypto.createHash("sha256").update(body).digest("hex")}"`;

http.createServer((req, res) => {
  if (req.headers["if-none-match"] === etag) {
    res.writeHead(304);
    return res.end();
  }
  res.writeHead(200, {
    "content-type": "application/json; charset=utf-8",
    "cache-control": "public, max-age=300",
    etag
  });
  res.end(body);
}).listen(3003);

Time outs, back pressure, and limits

Protect servers with sensible time outs and body size limits. Apply back pressure by pausing reads when downstream is slow, and by rejecting excessively large payloads early.

import http from "node:http";

const MAX = 1_000_000; // 1MB
const server = http.createServer((req, res) => {
  req.socket.setTimeout(30_000);
  let size = 0;
  const chunks: Buffer[] = [];
  req.on("data", c => {
    size += (c as Buffer).length;
    if (size > MAX) {
      res.writeHead(413);
      res.end("payload too large");
      req.destroy();
      return;
    }
    chunks.push(c as Buffer);
  });
  req.on("end", () => {
    res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
    res.end(`received ${size} bytes`);
  });
});

server.maxConnections = 1000;
server.listen(3004);

Measure under realistic traffic. Tune socket and server limits gradually. A small change to idle time outs or pool size can have a large effect on latency and memory use.