HTTPS, TLS & HTTP/2

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on Node.js 26, and on 24 and 22 unless the text says otherwise. The node:https, node:tls and node:http2 modules are all Stability 2 (Stable). node:tls implements Transport Layer Security on top of OpenSSL, node:https is HTTP/1.1 carried over a tls.Server, and node:http2 is the HTTP/2 protocol (on the Node.js 26.10.0 and 22.23.3 builds used here, process.versions reports OpenSSL 3.5.8 and nghttp2 1.70.0 and 1.69.0 respectively; these numbers are an observation about those two builds, not a documented guarantee). The running scenario is the Bookshelf application that grows across this section, described in Introduction & Architecture; plain-HTTP servers, routing and request handling are in HTTP Server Fundamentals, so this page only adds what changes when the connection is encrypted or multiplexed.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

node:https: https.createServer(), https.Agent, https.get(), https.request()

2 Stable

createServer() has been there since v0.3.4. https.Server supports Symbol.asyncDispose (no longer experimental since v24.2.0).

node:tls: tls.createServer(), tls.connect(), tls.createSecureContext(), server.addContext(), server.setSecureContext()

2 Stable

TLS 1.3 support arrived in v12.0.0. server.setSecureContext() was added in v11.0.0.

ALPNCallback option of tls.createServer()

2 Stable

Added in v20.4.0 and v18.19.0. Since v19.0.0, when ALPNProtocols is set, a client that sends an ALPN extension with no supported protocol is terminated with a fatal no_application_protocol alert.

tls.getCACertificates([type])

2 Stable

Added in v23.10.0 and v22.15.0.

tls.setDefaultCACertificates(certs)

2 Stable

Added in v24.5.0 and v22.19.0.

--use-system-ca, NODE_EXTRA_CA_CERTS

CLI options

--use-system-ca was added in v23.8.0 (Windows and macOS) and extended to other platforms in v23.9.0. NODE_EXTRA_CA_CERTS was added in v7.3.0.

certificateCompression option, tls.getCertificateCompressionAlgorithms()

2 Stable

Added in v26.4.0 (RFC 8879; only effective with TLS 1.3).

node:http2: Core API (createSecureServer(), connect(), Http2Session, Http2Stream)

2 Stable

Added in v8.4.0; Stable since v10.10.0 (it was Experimental before).

http2.createSecureServer() options http1Options and strictSingleValueFields

2 Stable

Both added in v25.7.0, so they are not available on Node.js 24 or 22.

HTTP/2 Compatibility API (Http2ServerRequest, Http2ServerResponse)

2 Stable

response.writeEarlyHints() was added in v18.11.0. Several aliases are Stability 0 (Deprecated): request.connection and response.connection (use .socket) and response.finished (use .writableEnded).

http2stream.pushStream()

2 Stable

Still present on Node.js 26 and still works (observed below); the web platform stopped using it, see Why server push is gone.

http2stream.priority()

0 Deprecated

Priority signaling was deprecated in RFC 9113; since v24.2.0 the method does nothing useful and triggers a runtime warning.

Certificates for development: self-signed with openssl

TLS needs an identity: a private key and a certificate that names the server. In production the certificate is issued by a public certificate authority, usually by the reverse proxy, load balancer or platform in front of Node.js (see Production Deployment). In development you generate a self-signed one, which is a certificate that vouches for itself. The command below is the one the Node.js documentation uses, extended with a subject alternative name, because modern clients match the hostname against the subjectAltName extension and ignore the common name when one is present.

Generating a development key and certificate (the files stay on your machine and out of version control)
mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 30 \
  -subj '/CN=localhost' \
  -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' \
  -keyout certs/localhost-key.pem -out certs/localhost-cert.pem

-nodes leaves the key unencrypted so the example needs no passphrase; for a key that matters, encrypt it and pass passphrase. Add certs/ to .gitignore and read the paths from the environment, as the examples below do. The Node.js TLS page also shows the longer flow with a certificate signing request (openssl req -new) and a PKCS#12 bundle (openssl pkcs12 -export), which you pass as pfx instead of key and cert.

https.createServer()

https.createServer(options[, requestListener]) accepts the options of tls.createServer(), tls.createSecureContext() and http.createServer() together, so everything on HTTP Server Fundamentals (the request listener, res.end(), server timeouts) works unchanged. The only additions are the identity (key and cert, or pfx) and the TLS policy. On the request, req.socket is a tls.TLSSocket, which is where you read the negotiated protocol and cipher.

A Bookshelf endpoint over HTTPS, called by an https.get() client that trusts the development certificate
import { createServer, get } from 'node:https';
import { readFileSync } from 'node:fs';

const server = createServer(
  {
    key: readFileSync(process.env.TLS_KEY_FILE),
    cert: readFileSync(process.env.TLS_CERT_FILE),
    minVersion: 'TLSv1.3',
  },
  (req, res) => {
    const { socket } = req;
    res.setHeader('content-type', 'application/json');
    res.end(JSON.stringify({
      encrypted: socket.encrypted,
      protocol: socket.getProtocol(),
      cipher: socket.getCipher().name,
      books: ['Dune'],
    }));
  },
);

server.listen(0, 'localhost', () => {
  const { port } = server.address();
  // The self-signed certificate is its own CA, so trust it explicitly.
  get(`https://localhost:${port}/books`, { ca: readFileSync(process.env.TLS_CERT_FILE) }, (res) => {
    let body = '';
    res.setEncoding('utf8');
    res.on('data', (chunk) => { body += chunk; });
    res.on('end', () => { console.log(res.statusCode, body); server.close(); });
  });
});

Run it with the paths in the environment, for example TLS_KEY_FILE=certs/localhost-key.pem TLS_CERT_FILE=certs/localhost-cert.pem node server.js (or put them in a .env file and use node --env-file=.env).

Output (identical on Node.js 26, 24 and 22)
200 {"encrypted":true,"protocol":"TLSv1.3","cipher":"TLS_AES_256_GCM_SHA384","books":["Dune"]}

The ca option on the client is the important line. A client that does not trust the certificate fails the handshake instead of sending the request, which is the behaviour you want to keep:

Output when the client does not pass ca (observed)
DEPTH_ZERO_SELF_SIGNED_CERT - self-signed certificate

That is the error.code and message of the 'error' event on the https.get() request. The tempting fix is rejectUnauthorized: false on the client or the NODE_TLS_REJECT_UNAUTHORIZED=0 environment variable; the Node.js CLI documentation says plainly that the latter makes TLS, and HTTPS by extension, insecure and is strongly discouraged. Trust the certificate instead, either per connection with ca, as above, or for the whole process with NODE_EXTRA_CA_CERTS or tls.setDefaultCACertificates(), described below.

The tls options that matter

tls.createServer() and tls.createSecureContext() have many options. The ones you meet in practice are in the table; the complete lists are on the tls API page.

Table 2. Common TLS options
Option What it does and why you care

key, cert, pfx, passphrase

The server identity. cert can hold a chain (leaf first); pfx is a PKCS#12 bundle that replaces key and cert.

ca

Overrides the trusted CAs. On a client this is how you trust a private CA; on a server it is the set of CAs that may sign client certificates (with requestCert).

minVersion, maxVersion

Bound the protocol versions. The default minimum, tls.DEFAULT_MIN_VERSION, is 'TLSv1.2'; --tls-min-v1.3 raises the process-wide default. Versions before TLS 1.2 also need the OpenSSL security level lowered, so leave them alone.

ciphers, honorCipherOrder

Replace the cipher suite list. The defaults are curated and prefer ECDHE for forward secrecy; changing them is rarely an improvement. TLS 1.3 suites are named separately from TLS 1.2 ones.

requestCert, rejectUnauthorized

Server side: ask clients for a certificate, and drop connections that fail verification. rejectUnauthorized defaults to true but only matters when requestCert is true. Client side: rejectUnauthorized defaults to true and should stay that way.

SNICallback, server.addContext()

Choose a certificate by requested hostname, see SNI: one server, many certificates.

ALPNProtocols, ALPNCallback

Choose the application protocol, see ALPN: choosing HTTP/1.1 or HTTP/2. The two options cannot be combined.

ticketKeys, sessionTimeout, sessionIdContext

Session resumption: share the 48-byte ticket keys between processes so clients can resume across them. sessionTimeout defaults to 300 seconds. ticketKeys is shared automatically between node:cluster workers.

handshakeTimeout

Abort a handshake that does not finish in time (default 120000 ms). A timeout emits 'tlsClientError' on the server.

crl, allowPartialTrustChain, sigalgs, ecdhCurve, dhparam

Revocation lists, trust anchors that are intermediate certificates (allowPartialTrustChain, added v22.9.0), signature algorithms and key-exchange tuning.

certificateCompression

RFC 8879 certificate compression for TLS 1.3 (added v26.4.0); check tls.getCertificateCompressionAlgorithms() for what the OpenSSL build supports.

The practical advice is short: keep the defaults, set minVersion: 'TLSv1.3' when you control all clients (every current browser and Node.js speak it), and put effort into certificate lifecycle rather than cipher lists.

SNI: one server, many certificates

Server Name Indication (RFC 6066) is a TLS extension in which the client sends the hostname it wants before the server has to show a certificate. It lets one listener on port 443 serve api.example.com and admin.example.com with different certificates. Node.js gives you two ways to use it: server.addContext(hostname, context), which registers a context for a hostname or wildcard (the most recently added matching one wins), and the SNICallback(servername, callback) option, which lets you decide per connection, for example by loading a certificate from a database. The callback is error-first; passing a falsy context selects the server’s default identity.

Selecting a certificate by requested name, and reading the answer from the client side
import { createServer } from 'node:https';
import { createSecureContext, connect } from 'node:tls';
import { readFileSync } from 'node:fs';

const pair = (name) => ({
  key: readFileSync(`certs/${name}-key.pem`),
  cert: readFileSync(`certs/${name}-cert.pem`),
});

const contexts = new Map([
  ['api.localhost', createSecureContext(pair('api'))],
  ['admin.localhost', createSecureContext(pair('admin'))],
]);

const server = createServer(
  {
    ...pair('localhost'), // default identity when no name matches
    SNICallback(servername, callback) {
      // callback(error, context); a falsy context selects the default identity
      callback(null, contexts.get(servername));
    },
  },
  (req, res) => res.end(`served for ${req.socket.servername}\n`),
);

server.listen(0, () => {
  const { port } = server.address();
  const probe = (servername) => new Promise((resolve) => {
    const socket = connect({ port, host: '127.0.0.1', servername, rejectUnauthorized: false }, () => {
      resolve(`${servername}: ${socket.getPeerCertificate().subject.CN}`);
      socket.end();
    });
  });
  const run = async () => {
    for (const name of ['api.localhost', 'admin.localhost', 'other.localhost']) console.log(await probe(name));
    server.close();
  };
  run();
});

The three certificates are generated with the same openssl req command as before, changing -subj and subjectAltName to api.localhost and admin.localhost. rejectUnauthorized: false appears only in this probe, because it inspects certificates rather than sending data.

Output (identical on Node.js 26 and 22)
api.localhost: api.localhost
admin.localhost: admin.localhost
other.localhost: localhost

The same page documents server.addContext(), the declarative alternative to the callback. On the client, tls.connect() sends the SNI name from its servername option; https.request() sets it from the URL host unless the host is an IP address.

ALPN: choosing HTTP/1.1 or HTTP/2

Application-Layer Protocol Negotiation (RFC 7301) is the other handshake extension. The client lists the protocols it speaks (h2, http/1.1) and the server picks one, so one TLS listener can serve several protocols. This is how a browser ends up on HTTP/2 without any extra round trip, and why HTTP/2 works only over TLS for browsers. With ALPNProtocols you list the server’s protocols in priority order; with ALPNCallback (since v20.4.0 and v18.19.0) you compute the answer from the client’s list and the SNI name, returning one of the client’s protocols or undefined to reject the connection.

Negotiating h2 over a bare tls server
import { createServer, connect } from 'node:tls';
import { readFileSync } from 'node:fs';

const server = createServer({
  key: readFileSync('certs/localhost-key.pem'),
  cert: readFileSync('certs/localhost-cert.pem'),
  ALPNCallback({ servername, protocols }) {
    // Pick the first protocol we support, in our own order of preference.
    return ['h2', 'http/1.1'].find((p) => protocols.includes(p));
  },
}, (socket) => {
  console.log('server negotiated:', socket.alpnProtocol);
  socket.end();
});

server.listen(0, () => {
  const socket = connect({
    port: server.address().port,
    host: '127.0.0.1',
    ca: readFileSync('certs/localhost-cert.pem'),
    servername: 'localhost',
    ALPNProtocols: ['http/1.1', 'h2'],
  }, () => {
    console.log('client sees:', socket.alpnProtocol, socket.getProtocol());
    socket.end();
  });
  socket.on('close', () => server.close());
});
Output (identical on Node.js 26 and 22; the two lines can appear in either order in principle)
client sees: h2 TLSv1.3
server negotiated: h2

socket.alpnProtocol is false when no ALPN extension was negotiated, which is what a plain https.get() client produces (see the compatibility example below); https.createServer() does not need ALPN configuration because it only speaks HTTP/1.1, and http2.createSecureServer() sets up ALPN for you.

Source: ALPN and SNI

Mutual TLS

With requestCert: true the server asks the client for a certificate and, with rejectUnauthorized: true, drops connections whose certificate is not signed by one of the CAs in the server’s ca option. That is mutual TLS (mTLS), a good fit for service-to-service calls where an API key would otherwise travel in a header. The handler reads the verified identity from req.socket.getPeerCertificate() and req.socket.authorized.

A Bookshelf endpoint that only accepts clients holding a certificate signed by the trusted CA
import { createServer, get } from 'node:https';
import { readFileSync } from 'node:fs';

const read = (f) => readFileSync(`certs/${f}`);

const server = createServer({
  key: read('localhost-key.pem'),
  cert: read('localhost-cert.pem'),
  minVersion: 'TLSv1.3',
  requestCert: true,          // ask the client for a certificate...
  rejectUnauthorized: true,   // ...and drop connections whose certificate is not signed by `ca`
  ca: [read('client-cert.pem')],
}, (req, res) => {
  const peer = req.socket.getPeerCertificate();
  res.end(`hello ${peer.subject.CN}, authorized=${req.socket.authorized}\n`);
});

server.on('tlsClientError', (err) => console.log('server tlsClientError:', err.code));

const request = (options) => new Promise((resolve) => {
  get(`https://localhost:${server.address().port}/`, { ca: read('localhost-cert.pem'), ...options }, (res) => {
    let body = '';
    res.on('data', (c) => { body += c; });
    res.on('end', () => resolve(body.trim()));
  }).on('error', (err) => resolve(`client error: ${err.code}`));
});

server.listen(0, async () => {
  console.log(await request({ key: read('client-key.pem'), cert: read('client-cert.pem') }));
  console.log(await request({}));
  server.close();
});
Output (identical on Node.js 26, 24 and 22; the order of the server and client lines can vary)
hello bookshelf-cli, authorized=true
server tlsClientError: ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE
client error: ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED

The client certificate here is generated with another openssl req -x509 command and doubles as its own CA. In a real deployment you would issue client certificates from a private CA and put only that CA in ca. Failed handshakes never reach the request listener; they surface as the 'tlsClientError' event on the server, which is where to log them.

Renewing certificates without a restart

Certificates expire, so a long-running server must be able to pick up a renewed one. server.setSecureContext(options) replaces the secure context of a running server; connections that are already open are not interrupted, and new handshakes use the new identity.

Hot-swapping the certificate of a running HTTPS server
import { createServer } from 'node:https';
import { connect } from 'node:tls';
import { readFile } from 'node:fs/promises';

const load = async (name) => ({
  key: await readFile(`certs/${name}-key.pem`),
  cert: await readFile(`certs/${name}-cert.pem`),
});

const server = createServer(await load('api'), (req, res) => res.end('ok\n'));

// Call this when your certificate renewal job has replaced the files (for example on SIGHUP).
const reload = async (name) => server.setSecureContext(await load(name));

const commonName = (port) => new Promise((resolve) => {
  const socket = connect({ port, host: '127.0.0.1', rejectUnauthorized: false }, () => {
    resolve(socket.getPeerCertificate().subject.CN);
    socket.end();
  });
});

server.listen(0, async () => {
  const { port } = server.address();
  console.log('before:', await commonName(port));
  await reload('admin'); // stands in for the renewed certificate
  console.log('after :', await commonName(port));
  server.close();
});
Output (identical on Node.js 26 and 22)
before: api.localhost
after : admin.localhost

For session resumption across processes or restarts, the servers must share ticket keys: generate 48 random bytes with crypto.randomBytes(48), hand them to ticketKeys (the option is shared automatically between node:cluster workers, see Cluster & Scaling) and rotate them regularly. The documentation stresses that these keys are cryptographic secrets and should not be stored on disk.

Trusting certificate authorities: getCACertificates()

By default a Node.js client trusts the Mozilla CA store that is bundled into the binary, not the operating system’s store. That matters in companies that inspect TLS with their own CA, and in development with a private CA. Four things control the trusted set:

  • --use-bundled-ca (the default) and --use-openssl-ca choose between the bundled Mozilla store and the OpenSSL store;

  • --use-system-ca adds the operating system store (added v23.8.0 on Windows and macOS, v23.9.0 on other platforms);

  • the NODE_EXTRA_CA_CERTS environment variable adds the certificates of one PEM file;

  • the ca option on a single connection or agent overrides all of the above for that connection.

tls.getCACertificates([type]) (added v23.10.0 and v22.15.0) shows what is in effect. type is 'default' (what clients use right now), 'bundled' (the Mozilla store, the same as tls.rootCertificates), 'system' (the operating system store, readable even without --use-system-ca) or 'extra' (the file named by NODE_EXTRA_CA_CERTS, an empty array when it is not set). tls.setDefaultCACertificates(certs) (added v24.5.0 and v22.19.0) replaces the default list for the current thread, which is the in-process alternative to the environment variable. It completely replaces the list, so to add a certificate you start from getCACertificates('default'), and it must run before the first connection you care about because sessions cached by the HTTPS agent are not affected.

Inspecting the trusted set, then trusting the development certificate for every later connection
import tls from 'node:tls';
import { createServer, get } from 'node:https';
import { readFileSync } from 'node:fs';

for (const type of ['default', 'bundled', 'system', 'extra']) {
  console.log(type.padEnd(8), tls.getCACertificates(type).length);
}

// Trust our development certificate for every later TLS connection in this thread.
const devCert = readFileSync(process.env.TLS_CERT_FILE, 'utf8');
tls.setDefaultCACertificates([...tls.getCACertificates('default'), devCert]);

const server = createServer(
  { key: readFileSync(process.env.TLS_KEY_FILE), cert: devCert },
  (req, res) => res.end('trusted\n'),
);
server.listen(0, () => {
  get(`https://localhost:${server.address().port}/`, (res) => {
    console.log('status', res.statusCode, '(no ca option passed)');
    res.resume();
    server.close();
  });
});
Output on Node.js 26.10.0 on the machine used for this page (the counts depend on the version and the machine; Node.js 24 and 22 print the same shape)
default  121
bundled  121
system   0
extra    0
status 200 (no ca option passed)

On this machine the system count was 0 without --use-system-ca; the certificate count of your system store will differ, and running with NODE_EXTRA_CA_CERTS=certs/localhost-cert.pem moved default up by one and extra to 1 in the same check.

Corporate proxies and enterprise CAs are covered with the HTTP clients in Fetch & HTTP Clients; the official Learn article on enterprise network configuration describes the same CA options from the administrator’s point of view.

HTTP/2

HTTP/2 (RFC 9113) keeps the semantics of HTTP, meaning methods, status codes and headers, and changes how they travel. A single connection carries many independent request and response exchanges at once, headers are compressed (HPACK), and the text framing of HTTP/1.1 becomes a binary one. The practical consequences for a Node.js server are fewer connections, no head-of-line blocking between requests on the same connection (it still exists at the TCP level), and a different programming model underneath. Node.js ships the implementation in node:http2, built on the nghttp2 library.

Sessions and streams

Two objects carry the model:

  • An Http2Session is the connection. It owns the settings (session.localSettings, session.remoteSettings), flow control, pings and the graceful shutdown (session.close(), session.goaway()), and emits 'stream' for each new exchange. On the server the events 'session' (on the server object) and 'close' (on the session) bracket its life.

  • An Http2Stream is one request/response exchange on that connection. It is a Duplex, so everything on Streams applies: stream.respond(headers) sends the response headers, stream.end(data) or stream.pipe() sends the body, stream.on('data') receives a request body. Stream identifiers are odd for client-initiated streams (1, 3, 5, …​).

The Core API is deliberately lower-level than node:http: you receive headers as an object that includes the pseudo-headers (:method, :path, :scheme, :authority and, on responses, :status), and you call respond() yourself. The http2.constants object exports names for the common headers. The module reference says that the Core API is much more symmetric between client and server than node:http: events such as 'error', 'connect' and 'stream' can occur on either side.

A Bookshelf HTTP/2 server with the Core API, and a client multiplexing three requests over one connection
import { createSecureServer, connect, constants } from 'node:http2';
import { readFileSync } from 'node:fs';

const { HTTP2_HEADER_PATH, HTTP2_HEADER_STATUS, HTTP2_HEADER_CONTENT_TYPE } = constants;
const books = [{ id: 1, title: 'Dune' }, { id: 2, title: 'Emma' }];

const server = createSecureServer({
  key: readFileSync(process.env.TLS_KEY_FILE),
  cert: readFileSync(process.env.TLS_CERT_FILE),
});

server.on('session', (session) => {
  console.log('server: session opened, alpn =', session.alpnProtocol);
  session.on('close', () => console.log('server: session closed'));
});

server.on('stream', (stream, headers) => {
  const path = headers[HTTP2_HEADER_PATH];
  const book = books.find((b) => path === `/books/${b.id}`);
  stream.respond({
    [HTTP2_HEADER_STATUS]: book ? 200 : 404,
    [HTTP2_HEADER_CONTENT_TYPE]: 'application/json',
  });
  stream.end(JSON.stringify(book ?? { error: 'not found' }));
});

server.listen(0, () => {
  const client = connect(`https://localhost:${server.address().port}`, {
    ca: readFileSync(process.env.TLS_CERT_FILE),
  });
  // Both requests share one TCP connection and one TLS session.
  const ask = (path) => new Promise((resolve) => {
    const req = client.request({ ':path': path });
    let status;
    let body = '';
    req.on('response', (h) => { status = h[':status']; });
    req.setEncoding('utf8');
    req.on('data', (c) => { body += c; });
    req.on('end', () => resolve(`${path} -> ${status} ${body} (stream ${req.id})`));
    req.end();
  });
  Promise.all([ask('/books/1'), ask('/books/2'), ask('/books/9')]).then((lines) => {
    lines.forEach((l) => console.log(l));
    client.close();
    server.close();
  });
});
Output (identical on Node.js 26, 24 and 22)
server: session opened, alpn = h2
/books/1 -> 200 {"id":1,"title":"Dune"} (stream 1)
/books/2 -> 200 {"id":2,"title":"Emma"} (stream 3)
/books/9 -> 404 {"error":"not found"} (stream 5)
server: session closed

Source: http2.connect()

Three details are worth knowing before you build on this. The first is that createSecureServer() is the one to use for browsers: the module documentation states that no browser is known to support unencrypted HTTP/2, so http2.createServer() (HTTP/2 over cleartext, often called h2c) is for internal traffic between services you control. The second is that the server is a tls.Server, so every TLS option in this page, SNICallback and mutual TLS included, goes into the same options object. The third is that the HTTP/2 specific limits are options of the same call: maxSessionMemory (default 10 megabytes per session), maxHeaderListPairs (default 128), peerMaxConcurrentStreams (default 100), maxSessionRejectedStreams and maxSessionInvalidFrames, and the settings object (maxConcurrentStreams, initialWindowSize, enablePush and others, see the settings reference). They exist because multiplexing moves resource exhaustion from "too many connections" to "too many streams or frames on one connection", which is why the stream-reset rate limits streamResetBurst and streamResetRate (defaults 1000 and 33) are on by default. Server timeouts and request limits for both protocols are the subject of Server Timeouts, Limits & Hardening.

Errors follow the same layering as the sessions: validation errors throw synchronously, while state, internal and protocol errors are reported through an 'error' event on the Http2Stream, the Http2Session or the server, so register 'error' listeners on each level you create (see Errors & Error Handling). For the session and stream APIs themselves, the reference pages are the authority.

Source: Http2Session

Source: Http2Stream

The compatibility API and allowHTTP1

Most Node.js code, including Express and Fastify, is written against (req, res). The Compatibility API gives node:http2 that shape: pass a request listener as the second argument and you receive Http2ServerRequest and Http2ServerResponse objects whose public API matches http.IncomingMessage and http.ServerResponse. It aims at the public API only, and it does not hide protocol differences; for instance, the HTTP status message is ignored because HTTP/2 has none.

Setting allowHTTP1: true on createSecureServer() lets one listener answer both protocols: ALPN picks h2 for clients that offer it and the server falls back to HTTP/1.x for the rest (a client that sends no ALPN at all, as a plain https.get() does, lands on HTTP/1.1). Your handler then must stick to the public HTTP/1 API and, if it cares, detect the protocol with req.httpVersion. Since v25.7.0 the http1Options option configures the HTTP/1 fallback server, for example its keepAliveTimeout.

One compat-API listener answering an HTTP/2 client and an HTTP/1.1 client
import { createSecureServer, connect } from 'node:http2';
import { get } from 'node:https';
import { readFileSync } from 'node:fs';

const ca = readFileSync(process.env.TLS_CERT_FILE);

// Compatibility API: (req, res) look like node:http, one server speaks h2 and http/1.1.
const server = createSecureServer(
  {
    key: readFileSync(process.env.TLS_KEY_FILE),
    cert: ca,
    allowHTTP1: true,
  },
  (req, res) => {
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({
      httpVersion: req.httpVersion,
      alpn: req.socket.alpnProtocol,
      url: req.url,
      authority: req.headers[':authority'] ?? req.headers.host,
    }));
  },
);

server.listen(0, () => {
  const port = server.address().port;

  // 1. An HTTP/1.1 client: node:https offers only http/1.1 via ALPN.
  get(`https://localhost:${port}/books`, { ca }, (res) => {
    res.setEncoding('utf8');
    res.on('data', (d) => console.log('https  :', d));
    res.on('end', () => {
      // 2. An HTTP/2 client against the very same listener.
      const client = connect(`https://localhost:${port}`, { ca });
      const req = client.request({ ':path': '/books' });
      req.setEncoding('utf8');
      req.on('data', (d) => console.log('http2  :', d));
      req.on('close', () => { client.close(); server.close(); });
      req.end();
    });
  });
});
Output (identical on Node.js 26 and 22; the port is chosen by the operating system)
https  : {"httpVersion":"1.1","alpn":false,"url":"/books","authority":"localhost:63261"}
http2  : {"httpVersion":"2.0","alpn":"h2","url":"/books","authority":"localhost:63261"}

Two more things to know about the compatibility layer. HTTP/2 requests carry :authority instead of the host header, and the page on :authority and host in the module reference explains how Node.js treats them, so read req.headers[':authority'] ?? req.headers.host as above or use req.authority. And the compat layer targets the public HTTP/1 API: libraries that reach into req.socket internals or monkey-patch the HTTP/1 classes are not supported on it. Prefer a framework’s documented HTTP/2 support (see Web Frameworks: Express & Fastify) over wiring it up by hand, and note that in many deployments the reverse proxy terminates TLS and speaks HTTP/2 to browsers while Node.js sees HTTP/1.1 behind it.

Why server push is gone

HTTP/2 server push let a server answer one request with additional responses the client had not asked for yet, for example sending /cover.css together with the HTML that references it. The Core API exposes it as http2stream.pushStream(headers, callback) on the server and a 'stream' event on the client session. It still works in Node.js, which is why the section title says "gone" about the web, not about the API. The rest of this section explains the difference.

Server push between two Node.js peers, and a client that opts out with the enablePush setting
import { createSecureServer, connect } from 'node:http2';
import { readFileSync } from 'node:fs';

const ca = readFileSync(process.env.TLS_CERT_FILE);
const server = createSecureServer({
  key: readFileSync(process.env.TLS_KEY_FILE),
  cert: ca,
});

server.on('stream', (stream, headers) => {
  console.log(`server: ${headers[':path']}, pushAllowed = ${stream.pushAllowed}`);
  if (headers[':path'] === '/' && stream.pushAllowed) {
    stream.pushStream({ ':path': '/cover.css' }, (err, pushStream) => {
      if (err) throw err;
      pushStream.respond({ ':status': 200, 'content-type': 'text/css' });
      pushStream.end('body { margin: 0 }');
    });
  }
  stream.respond({ ':status': 200, 'content-type': 'text/html' });
  stream.end('<link rel="stylesheet" href="/cover.css">');
});

const fetchPage = (port, settings) => new Promise((resolve) => {
  const client = connect(`https://localhost:${port}`, { ca, settings });
  client.on('stream', (pushed, reqHeaders) => {
    console.log('client: pushed stream for', reqHeaders[':path']);
    pushed.resume();
  });
  const req = client.request({ ':path': '/' });
  req.resume();
  req.on('close', () => setTimeout(() => { client.close(); resolve(); }, 50));
  req.end();
});

server.listen(0, async () => {
  const { port } = server.address();
  console.log('--- client allows push (the default setting)');
  await fetchPage(port, {});
  console.log('--- client sends enablePush: false');
  await fetchPage(port, { enablePush: false });
  server.close();
});
Output (identical on Node.js 26, 24 and 22)
--- client allows push (the default setting)
server: /, pushAllowed = true
client: pushed stream for /cover.css
--- client sends enablePush: false
server: /, pushAllowed = false

Two behaviours are visible: stream.pushAllowed mirrors the client’s SETTINGS_ENABLE_PUSH setting, so code must check it before pushing, and the client must consume (here, resume()) a pushed stream or its session stays open (observed: omitting that line made the run hang).

The reason for the epitaph is on the client side. The Chrome developers' article (published in August 2022) announced that HTTP/2 Server Push would be disabled by default in Chrome 106 and in other Chromium-based browsers in their next releases. Its reasons: the performance benefit was often difficult to realise, the analyses it cites showed mixed results with performance regressions in many cases, only about 1.25% of HTTP/2 sites used the feature (dropping to about 0.7% on re-measurement), and push was not implemented in many HTTP/3 servers and clients even though the specification includes it. RFC 9113 (June 2022), the current HTTP/2 specification, still defines push, and a client switches it off with the SETTINGS_ENABLE_PUSH setting; that is the setting enablePush: false sends in the example above. Consequently, a push that you write today reaches no Chromium-based browser with default settings. The Node.js documentation does not mark the API itself as deprecated.

The replacement the same article recommends is 103 Early Hints (RFC 8297), which it calls a much less error-prone alternative: the server sends an interim response with Link: …​; rel=preload headers before the final response, and the browser decides whether it needs those resources, for example because they are already in its HTTP cache. It also works over HTTP/1.1. Node.js supports it on both stacks with response.writeEarlyHints(hints) (added v18.11.0 for HTTP/2 and available on http.ServerResponse).

Sending 103 Early Hints from a compatibility-API handler
import { createSecureServer, connect } from 'node:http2';
import { readFileSync } from 'node:fs';

const ca = readFileSync(process.env.TLS_CERT_FILE);
const server = createSecureServer(
  { key: readFileSync(process.env.TLS_KEY_FILE), cert: ca, allowHTTP1: true },
  (req, res) => {
    // Send "103 Early Hints" first, then the real response.
    res.writeEarlyHints({ link: '</cover.css>; rel=preload; as=style' });
    setTimeout(() => {
      res.writeHead(200, { 'content-type': 'text/html' });
      res.end('<link rel="stylesheet" href="/cover.css">');
    }, 20);
  },
);

server.listen(0, () => {
  const client = connect(`https://localhost:${server.address().port}`, { ca });
  const req = client.request({ ':path': '/' });
  req.on('headers', (h) => console.log('informational:', h[':status'], h.link));
  req.on('response', (h) => console.log('final:', h[':status']));
  req.resume();
  req.on('close', () => { client.close(); server.close(); });
  req.end();
});
Output (identical on Node.js 26, 24 and 22)
informational: 103 </cover.css>; rel=preload; as=style
final: 200

The article’s other suggestion is preloading critical resources from the page itself; it adds that all of these early-loading techniques can cause performance degradation and should be used in moderation. Use Early Hints or <link rel="preload">, and plain multiplexing (many small requests are cheap on HTTP/2), instead of push.

Pitfalls

Table 3. Common mistakes
Mistake What to do instead

Disabling certificate verification (rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED=0) to get past a self-signed certificate

Pass the certificate as ca, or add it with NODE_EXTRA_CA_CERTS or tls.setDefaultCACertificates(). The workaround leaks into production far too easily.

Generating a certificate with only a common name

Add a subjectAltName for every hostname and IP you use; clients validate against it.

Committing keys or certificates, or pasting key material into code

Keep certs/ out of version control and read the paths from process.env, for example with node --env-file.

Expecting a browser to speak HTTP/2 to http2.createServer()

Browsers use HTTP/2 only over TLS; use createSecureServer() for them and reserve cleartext HTTP/2 for trusted internal links.

Putting allowHTTP1 handlers on the Core API

With allowHTTP1: true the request listener receives HTTP/1 objects for the fallback clients; use the Compatibility API and its public subset.

Forgetting 'error' listeners on sessions and streams

HTTP/2 reports state, internal and protocol errors through events on the stream, session or server; an unhandled 'error' crashes the process.

Assuming certificate expiry is someone else’s problem

Automate renewal and use server.setSecureContext() (or a proxy that does it) so a renewal does not need a restart.

Building a feature on pushStream()

Use 103 Early Hints and preload links; browsers disabled push.

What changed recently

Table 4. Notable changes and their versions
Version Change

v18.11.0

response.writeEarlyHints() added to the HTTP/2 compatibility response (and http.ServerResponse).

v19.0.0

With ALPNProtocols set, clients sending an ALPN extension without a supported protocol are rejected with a fatal no_application_protocol alert.

v20.4.0, v18.19.0

ALPNCallback option for tls.createServer().

v22.9.0, v20.18.0

allowPartialTrustChain option for tls.createSecureContext().

v22.15.0, v23.10.0

tls.getCACertificates() added.

v23.8.0, v23.9.0

--use-system-ca added; support beyond Windows and macOS followed in v23.9.0.

v24.2.0

http2stream.priority() no longer sets priority and warns, because RFC 9113 deprecated priority signaling.

v24.5.0, v22.19.0

tls.setDefaultCACertificates() added.

v25.7.0

http2.createSecureServer() gains http1Options and strictSingleValueFields.

v26.4.0

certificateCompression option and tls.getCertificateCompressionAlgorithms() (TLS 1.3 certificate compression, RFC 8879).

Where the books are outdated

Neither of the two books spends real time on this topic, so the useful statement is about what to add to them. Samer Buna’s Efficient Node.js (O’Reilly, 2025) mentions the https and http2 modules only in passing, as part of the list of built-ins next to http, before recommending frameworks, and lists --trace-tls among the options without showing a TLS server. Jonathan Wexler’s Node.js Projects (O’Reilly, 2025) calls port 443 "SSL" traffic; the protocol that browsers and Node.js use today is TLS, and the SSL versions are long retired. Nothing in either book builds a certificate, configures SNI or ALPN, or serves HTTP/2, so the guidance on this page is additional to both rather than a correction. The most important modern points the books do not give you are the rules in Why server push is gone (server push is not usable on the web), getCACertificates() and --use-system-ca for private and enterprise CAs (Node.js 22.15 and later), and the fact that certificate verification must stay on in development as well as in production. Older tutorials elsewhere also still show http2.createServer() for browsers, TLS 1.0 or 1.1 settings and rejectUnauthorized: false; none of that applies today.

Where to go next

References