HTTP/2 in Node.js
HTTP/2 is a binary, multiplexed evolution of HTTP/1.1 that fixes the protocol’s biggest performance ceiling: head-of-line blocking. Instead of opening many connections and sending one request per connection at a time, HTTP/2 carries every request and response as an independent stream over a single TCP connection, compresses headers, and lets the server prioritize traffic. Node.js exposes this through the built-in node:http2 module, which offers both a low-level stream API and a familiar compatibility layer that looks just like node:http. This page covers building a secure HTTP/2 server, working with streams and headers, and the trade-offs of features like server push.
Why HTTP/2 matters
Under HTTP/1.1 a browser typically opens six connections per origin and still serializes requests within each one. HTTP/2 collapses that into a single connection where dozens of requests fly in parallel as multiplexed streams. The key wins:
| Feature | HTTP/1.1 | HTTP/2 |
|---|---|---|
| Connections per origin | ~6 | 1 |
| Concurrency | One in-flight request per connection | Many multiplexed streams |
| Header encoding | Plain text, repeated every request | HPACK-compressed |
| Framing | Text-based | Binary frames |
| Prioritization | None | Stream weights and dependencies |
Because everything shares one connection, the TLS handshake and TCP slow-start cost is paid once, and HPACK header compression eliminates the redundant cookie/auth headers that bloat every HTTP/1.1 request.
Creating a secure HTTP/2 server
In practice HTTP/2 is always used over TLS. Browsers refuse to speak cleartext HTTP/2 (h2c), so you create a secure server with http2.createSecureServer() and supply a key/certificate pair exactly as you would for HTTPS. Node negotiates the h2 protocol during the TLS handshake via ALPN automatically.
import { createSecureServer } from "node:http2";
import { readFileSync } from "node:fs";
const server = createSecureServer({
key: readFileSync("./certs/key.pem"),
cert: readFileSync("./certs/cert.pem"),
});
server.on("stream", (stream, headers) => {
const path = headers[":path"];
stream.respond({
":status": 200,
"content-type": "text/plain; charset=utf-8",
});
stream.end(`You requested ${path} over HTTP/2\n`);
});
server.listen(8443, () => {
console.log("HTTP/2 server running at https://localhost:8443/");
});
The CommonJS form is identical aside from the import:
const { createSecureServer } = require("node:http2");
Generate a local cert with
openssl req -x509 -newkey rsa:2048 -nodes -keyout certs/key.pem -out certs/cert.pem -days 365 -subj "/CN=localhost". Test the server withcurl -k --http2 https://localhost:8443/— the--http2flag forces the upgraded protocol.
Streams and pseudo-headers
Each request/response exchange is an Http2Stream — a duplex stream you read from and write to. The native 'stream' event hands you the stream plus a headers object. HTTP/2 replaces the HTTP/1.1 request line and status line with pseudo-headers prefixed by a colon: :method, :path, :scheme, and :authority on requests, and :status on responses. Regular headers are always lowercase.
server.on("stream", (stream, headers) => {
if (headers[":method"] === "POST") {
let body = "";
stream.setEncoding("utf8");
stream.on("data", (chunk) => (body += chunk));
stream.on("end", () => {
stream.respond({ ":status": 201, "content-type": "application/json" });
stream.end(JSON.stringify({ received: body.length }));
});
return;
}
stream.respond({ ":status": 200 });
stream.end("ok\n");
});
Because each stream is independent, a slow upload on one stream never blocks responses on another — that is multiplexing in action. You can also stream a file efficiently with stream.respondWithFile(), which sets the content-length and pipes the data for you.
Multiplexing in practice
A single client connection can interleave many concurrent streams. Sending three responses from one handler shows how Node tracks each stream by its numeric id:
server.on("stream", (stream) => {
console.log("opened stream", stream.id);
stream.respond({ ":status": 200 });
stream.end(`served on stream ${stream.id}\n`);
});
Output:
opened stream 1
opened stream 3
opened stream 5
Client-initiated stream IDs are always odd and monotonically increasing; the server never has to manage connection pooling because the multiplexing happens inside one socket.
The compatibility API
Rewriting handlers to use pseudo-headers is a lot of churn for existing apps. The compatibility API lets createSecureServer() accept the same (req, res) callback as http.createServer(), exposing Http2ServerRequest and Http2ServerResponse objects that mimic the HTTP/1.1 interface — req.url, req.method, res.writeHead(), res.end(). Most frameworks (Express, Fastify) run on this layer unchanged.
import { createSecureServer } from "node:http2";
import { readFileSync } from "node:fs";
const server = createSecureServer(
{
key: readFileSync("./certs/key.pem"),
cert: readFileSync("./certs/cert.pem"),
allowHTTP1: true, // fall back to HTTP/1.1 for older clients
},
(req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ httpVersion: req.httpVersion, url: req.url }));
},
);
server.listen(8443);
The allowHTTP1: true option is important: it lets the same server serve HTTP/1.1 clients that don’t negotiate h2, so you don’t strand older consumers.
Server push and its deprecation
HTTP/2 originally let a server proactively push resources a client hadn’t requested yet (via stream.pushStream()), aiming to pre-load assets like CSS before the browser asked. In practice push wasted bandwidth pushing already-cached resources and was hard to tune, so Chrome removed support in 2022 and the feature is effectively dead on the web. Node still implements pushStream(), but you should not build on it.
The modern replacement is the 103 Early Hints response combined with <link rel="preload"> headers, which tells the browser what to fetch without the server guessing what’s cached:
server.on("stream", (stream) => {
stream.additionalHeaders({
":status": 103,
link: "</style.css>; rel=preload; as=style",
});
stream.respond({ ":status": 200, "content-type": "text/html" });
stream.end("<link rel=stylesheet href=/style.css><h1>Hi</h1>");
});
Best practices
- Always run HTTP/2 over TLS with
createSecureServer(); browsers don’t support cleartexth2c. - Set
allowHTTP1: trueso a single server gracefully serves both HTTP/2 and legacy HTTP/1.1 clients. - Prefer the compatibility
(req, res)API for existing apps and frameworks; reach for the native'stream'API only when you need fine-grained control. - Avoid server push — use
103 Early Hintswith preload links instead, since browsers have dropped push. - Handle stream-level errors (
stream.on('error', ...)) and respectsession.on('error'); one bad stream should not crash the connection. - In production, terminate HTTP/2 at a reverse proxy (nginx, Caddy, a cloud load balancer) that handles ALPN and certificates, and tune
maxConcurrentStreamsto bound resource use.