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.
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");
});
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"));
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"));
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);
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.