Setting up and testing a Node.js HTTP2 server
Standing up a Node.js HTTP2 server is five lines. Migrating to one is a short list of decisions those five lines hide, and every one of them is checkable before you ship.
Does a listening Node.js HTTP2 server prove anything negotiated HTTP/2?#
No. A process bound to a port says nothing about which protocol the connections reaching it actually used.
The usual starting shape is small.
import { readFileSync } from 'node:fs';
import http2 from 'node:http2';
const server = http2.createSecureServer({
key: readFileSync('key.pem'),
cert: readFileSync('cert.pem'),
});
server.on('stream', (stream, headers) => {
stream.respond({ ':status': 200, 'content-type': 'text/plain' });
stream.end('ok\n');
});
server.listen(8443); That runs. What it does not tell you is what happens when a client arrives without h2 in its ALPN extension. Node's documentation is direct about that: "If the client sends an ALPN extension that does not include HTTP/2 (or HTTP/1.1 if allowHTTP1 is true), the TLS handshake will fail and no secure connection will be established." So a connection either negotiates something you accept or it does not connect at all, and from inside the process the difference is invisible until you look for it.
What is a session, and what is a stream, in your own code?#
One TCP connection carries one HTTP/2 session, and that session carries many streams at once, so a client no longer opens several connections to the same server to get concurrency.
It does not make any single response arrive sooner. The HTTP/2 specification, RFC 9113, published June 2022 on the Standards Track, states the problem it solved:
Mapped onto the objects a Node.js HTTP2 server handler holds, the node:http2 documentation is concrete. A server-side Http2Stream is created when a new HEADERS frame arrives, and that is what surfaces as the 'stream' event. Every Http2Stream is a Duplex stream. The session sits above it as stream.session, and the socket below that as stream.session.socket.
For how a request reaches a socket in the first place, we walk that path in what happens when you hit a URL, and the HTTP/1.1 server this one replaces is the sibling to read first if you have not built one.
Is HTTP/2 always faster than HTTP/1.1?#
No. Whether HTTP/2 helps a workload is conditional, and one mechanism makes things worse. RFC 9113 says so in its own introduction: "Note, however, that TCP head-of-line blocking is not addressed by this protocol." RFC 9114, the HTTP/3 specification, explains what that costs you: "because the parallel nature of HTTP/2's multiplexing is not visible to TCP's loss recovery mechanisms, a lost or reordered packet causes all active transactions to experience a stall regardless of whether that transaction was directly impacted by the lost packet."
What the HTTP/1.1 alternative costs#
The HTTP/1.1 workaround is not free either. RFC 9114 again: "Because HTTP/1.1 does not include a multiplexing layer, multiple TCP connections are often used to service requests in parallel. However, that has a negative impact on congestion control and network efficiency, since TCP does not share congestion control across multiple connections." And RFC 9000, the QUIC specification, states plainly why a successor protocol exists at all: "One of the benefits of QUIC is avoidance of head-of-line blocking across multiple streams. When a packet loss occurs, only streams with data in that packet are blocked waiting for a retransmission to be received, while other streams can continue making progress."
Put those mechanisms side by side and a conclusion follows: on a lossy link, one HTTP/2 connection can fare worse than several parallel HTTP/1.1 connections, because every stream shares the fate of one packet. That sentence is reasoned from the quoted mechanisms rather than measured, and no RFC states it. None of these documents publishes a threshold, so there is no loss rate at which the answer flips.
Where your time actually goes is a measurement problem rather than a protocol one, which is the job our web performance work exists to do.
Which API surface should you pick?#
Pick by whether your existing request and response handlers have to survive the move.
A Node.js HTTP2 server has three surfaces. The native stream API gives you server.on('stream', (stream, headers, flags) => {}), the shape shown above. The compatibility API gives you familiar req and res objects. And createSecureServer with allowHTTP1: true keeps serving HTTP/1.1 clients from the same socket.
| API surface | What your handler receives | What survives the move |
|---|---|---|
| Native stream API | What your handler receivesserver.on('stream', (stream, headers, flags) => {}) | What survives the moveExisting handlers survive unchanged only if they were written for this shape. Otherwise the handler signature is rewritten. |
| Compatibility API | What your handler receivesFamiliar req and res objects | What survives the moveSurvives with documented gaps. Upgrading from non-tls HTTP/1 servers is not supported, and status message returns an empty string. |
| createSecureServer with allowHTTP1: true | What your handler receivesHTTP/1.x clients on the same socket as h2 clients | What survives the moveNothing needs a flag day, because both protocols are served from one port. |
What survives, what breaks#
The compatibility API is scoped narrowly, and Node says so: "This API targets only the public API of the [HTTP/1]. However many modules use internal methods or state, and those are not supported as it is a completely different implementation." Two documented gaps matter in practice. "Upgrading from non-tls HTTP/1 servers is not supported." And "Status message is not supported by HTTP/2 (RFC 7540 8.1.2.4). It returns an empty string."
Reading res.statusMessage over a real h2 connection returns an empty string, confirmed on a live server rather than inferred.
Three axes decide it. Existing handlers survive unchanged on the native API only if they were written for it, survive with documented gaps on the compatibility API, and do not survive at all if they reach into HTTP/1 internals. The shape of the change is a judgement: the native API asks you to rewrite your handler signature, the compatibility API asks you to audit your middleware. Whether a given framework supports HTTP/2 is a question for that framework's documentation.
Where does the protocol actually get chosen?#
Inside the TLS handshake, by ALPN.
Node's documentation puts it plainly: "ALPN negotiation allows supporting both [HTTPS] and HTTP/2 over the same socket." The identifiers are registered names: h2 is HTTP/2 over TLS and h2c is HTTP/2 over cleartext TCP, both in the IANA ALPN Protocol IDs registry the Node docs point at.
Node does the wiring. Reading lib/internal/http2/core.js, createSecureServer sets ALPNProtocols to ['h2'] itself, and appends 'http/1.1' only when allowHTTP1 is true and only when you supplied no ALPNCallback:
// lib/internal/http2/core.js, initializeTLSOptions()
if (!options.ALPNCallback) {
options.ALPNProtocols = ['h2'];
if (options.allowHTTP1 === true)
options.ALPNProtocols.push('http/1.1');
} What the handshake does#
[ok] h2 selected[ok] The handshake completes and the connection speaks HTTP/2.
The rule this rests on: createSecureServer sets ALPNProtocols to ['h2'] itself, and h2 sits first in the server's own preference order.
Set what the client puts in its ALPN extension and how the server was constructed, and the outcome is the one Node's documented rules produce. The complete truth table below is computed on the server, so every fact here is present with JavaScript disabled.
| Client offers | Server constructed as | Outcome | Rule |
|---|---|---|---|
| ALPN extension listing h2 | createSecureServer(), allowHTTP1 left unset | [ok] h2 selected. The handshake completes and the connection speaks HTTP/2. | createSecureServer sets ALPNProtocols to ['h2'] itself, and h2 sits first in the server's own preference order. |
| ALPN extension listing h2 | createSecureServer() with allowHTTP1: true | [ok] h2 selected. The handshake completes and the connection speaks HTTP/2. | createSecureServer sets ALPNProtocols to ['h2'] itself, and h2 sits first in the server's own preference order. |
| ALPN extension listing http/1.1 | createSecureServer(), allowHTTP1 left unset | [x] Handshake failed. No secure connection is established, so nothing reaches your code at all. | Node: "If the client sends an ALPN extension that does not include HTTP/2 (or HTTP/1.1 if allowHTTP1 is true), the TLS handshake will fail and no secure connection will be established." |
| ALPN extension listing http/1.1 | createSecureServer() with allowHTTP1: true | [ok] http/1.1 selected. The handshake completes and the connection speaks HTTP/1.1 on the same socket. | Node appends 'http/1.1' to ALPNProtocols only when allowHTTP1 is true and only when you supplied no ALPNCallback. |
| ALPN extension listing h2 and http/1.1 | createSecureServer(), allowHTTP1 left unset | [ok] h2 selected. The handshake completes and the connection speaks HTTP/2. | createSecureServer sets ALPNProtocols to ['h2'] itself, and h2 sits first in the server's own preference order. |
| ALPN extension listing h2 and http/1.1 | createSecureServer() with allowHTTP1: true | [ok] h2 selected. The handshake completes and the connection speaks HTTP/2. | createSecureServer sets ALPNProtocols to ['h2'] itself, and h2 sits first in the server's own preference order. |
| No ALPN extension at all | createSecureServer(), allowHTTP1 left unset | [!] 'unknownProtocol'. The 'unknownProtocol' event fires, and unknownProtocolTimeout defaults to 10000 ms. | Node emits 'unknownProtocol' only when the client sends no ALPN extension at all (nodejs.org/api/http2.html). |
| No ALPN extension at all | createSecureServer() with allowHTTP1: true | [!] 'unknownProtocol'. The 'unknownProtocol' event fires, and unknownProtocolTimeout defaults to 10000 ms. | Node emits 'unknownProtocol' only when the client sends no ALPN extension at all (nodejs.org/api/http2.html). |
Since v19.0.0, a connection offering no protocol you support is terminated with a fatal no_application_protocol alert, per the node:tls documentation. That same page defines no default for ALPNProtocols at all.
A plain TCP endpoint has no TLS handshake, so there is no ALPN exchange in which h2 could be selected. Against an http2.createServer on port 8080, an ordinary request printed 0 000: no HTTP version and no status code. The same server answered 2 200 once curl was told in advance to speak HTTP/2:
curl -s -o /dev/null -w '%{http_version} %{http_code}' http://localhost:8080/
# -> 0 000
curl -s --http2-prior-knowledge -o /dev/null -w '%{http_version} %{http_code}' http://localhost:8080/
# -> 2 200 So h2c is reachable only by a client told in advance what to speak.
What must a local certificate carry before a browser accepts it?#
The hostname has to live in the subjectAltName extension, because the Common Name field is no longer where certificate identity lives. The CA/Browser Forum Baseline Requirements are unambiguous: "For Subscriber Certificates, the Subject Alternative Name MUST be present and MUST contain at least one dNSName or iPAddress GeneralName." On the Common Name the same document marks it "NOT RECOMMENDED" and adds that if present it "MUST contain a value derived from the subjectAltName extension ...". RFC 9525, which is standards track and current, states the reason: "The Common Name RDN MUST NOT be used to identify a service because it is not strongly typed (it is essentially free-form text) and therefore suffers from ambiguities in interpretation."
The practical consequence is sourced for one client. Chrome removed commonName matching in Chrome 58, in 2017, and the Chromium feature record explains why the fallback lasted: "The fallback to the commonName was deprecated in RFC 2818 (published in 2000), but support still remains in a number of TLS clients, often incorrectly."
One OpenSSL invocation produces a certificate carrying the right extension:
openssl req -x509 -newkey rsa:2048 -noenc \
-keyout localhost-key.pem -out localhost-cert.pem \
-days 365 -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
# verify the SAN actually landed:
openssl x509 -in localhost-cert.pem -noout -text | grep -A1 "Subject Alternative Name" Run against OpenSSL 3.6.4, that exits 0 and verification prints X509v3 Subject Alternative Name: DNS:localhost, IP Address:127.0.0.1. Use -noenc rather than -nodes, deprecated by OpenSSL 3.0 in its favour, and note that -addext is documented from 1.1.1 onward. If you prefer a local CA, mkcert -install then mkcert localhost 127.0.0.1 ::1 does the same job, and its README is blunt about the trade: the rootCA-key.pem it generates "gives complete power to intercept secure requests from your machine". The same README scopes where it belongs: mkcert is for development, and not for end users' machines. So install that CA on a machine you control, and not on a shared box or a CI runner.
Can one port serve both HTTP/2 and HTTP/1.1?#
Yes. allowHTTP1 on createSecureServer lets HTTP/1.x clients land on the same socket as h2 clients, so the migration needs no flag day.
import http2 from 'node:http2';
import { readFileSync } from 'node:fs';
const server = http2.createSecureServer({
key: readFileSync('localhost-key.pem'),
cert: readFileSync('localhost-cert.pem'),
allowHTTP1: true,
});
server.on('request', (req, res) => {
if (req.httpVersion === '2.0') {
res.end(`alpn: ${req.stream.session.socket.alpnProtocol}\n`);
} else {
res.end('served over http/1.1\n');
}
});
server.on('unknownProtocol', (socket) => socket.destroy());
server.listen(8443); Against that shape on port 8443, curl --http2 reported http_version of 2 and curl --http1.1 reported 1.1, from one process and one port. The 'unknownProtocol' event fires when a client negotiates neither, and unknownProtocolTimeout defaults to 10000 ms.
One caveat about rollback. allowHTTP1 is an option passed in code, not a runtime switch, so backing it out is an edit and a restart. How that lands depends on how you deploy, and shipping it safely is deployment pipeline work rather than protocol work.
Is server push still usable? Chrome and Firefox removed it#
Node still ships http2stream.pushStream() with no deprecation notice. The browser engines that consumed pushed streams walked away instead.
In the Node documentation, pushStream() carries no Stability notice and no DEP identifier, and the SETTINGS field reads: "enablePush {boolean} Specifies true if HTTP/2 Push Streams are to be permitted on the Http2Session instances. Default: true." The deprecation that does exist in node:http2 is about something else entirely: "Stability: 0 - Deprecated: support for priority signaling has been deprecated in the [RFC 9113] and is no longer supported in Node.js."
Two client engines are verified. Chrome Platform Status feature 6302414934114304 describes its change as: "Remove the ability to receive, keep in memory, and use HTTP/2 push streams sent by the server. Send SETTINGS_ENABLE_PUSH = 0 at the beginning of every HTTP/2 connection to request that servers not send them." That shipped in Chrome milestone 106. Mozilla's tracker records the same direction: bug 1915848, "Pref off HTTP/2 push", resolved fixed against 132 Branch on 2024-09-11, then bug 1955565, "Remove HTTP/2 Push code", resolved fixed against 139 Branch on 2025-04-02. Chrome and Firefox are verified, and that is the extent of the claim.
- Chrome milestone 106
Chrome stops receiving pushed streams
Chrome Platform Status feature 6302414934114304: remove the ability to receive, keep in memory, and use HTTP/2 push streams sent by the server, and send SETTINGS_ENABLE_PUSH = 0 at the beginning of every HTTP/2 connection.
- 2024-09-11
Firefox prefs the feature off
Bugzilla bug 1915848, "Pref off HTTP/2 push", resolved fixed against 132 Branch.
- 2025-04-02
Firefox removes the code
Bugzilla bug 1955565, "Remove HTTP/2 Push code", resolved fixed against 139 Branch.
The documented onward path is Early Hints. response.writeEarlyHints(hints) arrived in Node v18.11.0 and "Sends a status 103 Early Hints to the client with a Link header, indicating that the user agent can preload/preconnect the linked resources."
response.writeEarlyHints({ 'link': '</styles.css>; rel=preload; as=style' }); Which HTTP/2 settings matter, and how do you prove a change landed?#
Node documents no default for your own maxConcurrentStreams, while peerMaxConcurrentStreams, what Node assumes about the far side until a SETTINGS frame says otherwise, defaults to 100. That asymmetry is easy to miss.
The SETTINGS object table documents headerTableSize at 4096 bytes, initialWindowSize at 65535 bytes, peerMaxConcurrentStreams at 100, enablePush at true, and maxFrameSize at 16384 bytes, alongside the header size limits.
A separate list on the same page covers createSecureServer options, which are not SETTINGS fields and must not be read as though they were: unknownProtocolTimeout at 10000 ms, maxSettings at 32, maxSessionInvalidFrames at 1000, and maxSessionRejectedStreams at 100.
| Group | Name | Documented default |
|---|---|---|
| SETTINGS object | NamemaxConcurrentStreams | Documented defaultNo default documented |
| SETTINGS object | NamepeerMaxConcurrentStreams | Documented default100 |
| SETTINGS object | NameheaderTableSize | Documented default4096 bytes |
| SETTINGS object | NameinitialWindowSize | Documented default65535 bytes |
| SETTINGS object | NamemaxFrameSize | Documented default16384 bytes |
| SETTINGS object | NameenablePush | Documented defaulttrue |
| createSecureServer option | NameunknownProtocolTimeout | Documented default10000 ms |
| createSecureServer option | NamemaxSettings | Documented default32 |
| createSecureServer option | NamemaxSessionInvalidFrames | Documented default1000 |
| createSecureServer option | NamemaxSessionRejectedStreams | Documented default100 |
One reconciliation is worth stating. Calling require('node:http2').getDefaultSettings() on a running Node.js HTTP2 server prints maxConcurrentStreams: 4294967295, the size of the protocol's stream identifier space in our own words. The documentation states no default for that field, so do not read the runtime number as a published guarantee.
Because settings are negotiated rather than assigned, the call returning is not proof:
session.settings({ maxConcurrentStreams: 100 });
session.once('localSettings', (settings) => {
console.log('acknowledged', settings.maxConcurrentStreams);
}); New settings do not take effect until the 'localSettings' event is emitted.
What breaks a single stream, and what takes down the whole session?#
Node separates failure into two levels. A frame error associated with a stream destroys that stream, while a frame error not associated with a stream shuts the whole session down, so handlers belong on both objects.
At the stream level#
'close' is emitted when the Http2Stream is destroyed, and "Once this event is emitted, the Http2Stream instance is no longer usable." Read the code it closed with from http2stream.rstCode, which "Will be undefined if the Http2Stream has not been closed." State flags are http2stream.closed and http2stream.destroyed, and http2stream.headersSent is a read-only boolean.
Node destroys a stream when both sides send END_STREAM, when the peer sends an RST_STREAM frame, or when close(), destroy() or http2session.destroy() is called locally. 'close' is always emitted on destroy. Calling http2stream.close() sends an RST_STREAM frame defaulting to http2.constants.NGHTTP2_NO_ERROR (0x00), so a close is a clean reset rather than an error. Stream-level 'frameError' is terminal for that stream: "The Http2Stream instance will be destroyed immediately after the 'frameError' event is emitted."
At the session level#
The session has its own 'error', "emitted when an error occurs during the processing of an Http2Session", and its own 'close', "emitted once the Http2Session has been destroyed". The GOAWAY asymmetry is worth knowing: receiving one shuts the session down automatically, while http2session.goaway() "Transmits a GOAWAY frame to the connected peer without shutting down the Http2Session." Meanwhile http2session.close() "Gracefully closes the Http2Session, allowing any existing streams to complete on their own and preventing new Http2Stream instances from being created", where destroy() terminates session and socket immediately and sends a final GOAWAY defaulting to INTERNAL_ERROR when an error was passed.
One shared fourteen-row table of error codes, 0x00 through 0x0d, serves both RST_STREAM and GOAWAY.
server.on('stream', (stream) => {
stream.on('error', (err) => log('stream error', err.code));
stream.on('close', () => log('stream closed', stream.rstCode, stream.destroyed));
stream.setTimeout(5000, () => stream.close(http2.constants.NGHTTP2_CANCEL));
});
server.on('session', (session) => {
session.on('error', (err) => log('session error', err.code));
session.on('close', () => log('session closed'));
session.on('goaway', (code) => log('peer sent goaway', code));
}); Timeouts deserve their own sentence, because the attribution is the point. Neither http2stream.setTimeout() nor http2session.setTimeout() publishes a default, so those timers are off until you arm them. The one level that does publish a default is the server, where server.setTimeout(), server.timeout and the 'timeout' event all carry Default: 0 (no timeout), changed from 120 seconds in v13.0.0.
When should you not do this work at all?#
If a reverse proxy, load balancer or CDN terminates HTTP/2 in front of your application, the hop to Node is a separate connection, so rewriting the Node server buys nothing on that hop that a browser measurement would show. Changing nothing is often right.
Trace the hops: browser, edge, reverse proxy, Node. Find where h2 ends. When it ends at the edge, the proxy negotiates the connection to your process, so those decisions belong to a component whose own documentation you should read instead.
When Node terminates TLS for client connections itself, every decision above is yours: the ALPN negotiation, the certificate, the SETTINGS defaults and the failure surface. If you are still deciding which responsibilities belong at the edge, what belongs at the edge works through that split.
How do you prove the connection actually negotiated h2?#
Confirm it from outside the process. Inside your handler, a client that silently fell back to HTTP/1.1 over TLS looks identical to one that did what you wanted.
The cheapest check on a Node.js HTTP2 server reads curl's http_version write-out variable, documented as "The http version that was effectively used", and throws the body away.
--http2 negotiates HTTP/2 in the TLS handshake for HTTPS URLs, while --http2-prior-knowledge skips negotiation and speaks HTTP/2 straight at a cleartext endpoint. For the readable form, curl -skv --http2 printed "* ALPN: curl offers h2,http/1.1", then "* ALPN: server accepted h2", then "* using HTTP/2".
In-process, read req.stream.session.socket.alpnProtocol, which is the building block Node's own ALPN negotiation example uses. In a test, assert on http2session.alpnProtocol, documented as undefined before connection, 'h2c' when the session is not on a TLSSocket, and otherwise the connected TLSSocket's own value:
curl -o /dev/null -s -w '%{http_version}\n' --http2 https://localhost:8443/
# -> 2
curl -o /dev/null -s -w '%{http_version}\n' --http1.1 https://localhost:8443/
# -> 1.1 curl -skv --http2 https://localhost:8443/
# * ALPN: curl offers h2,http/1.1
# * ALPN: server accepted h2
# * using HTTP/2 import test from 'node:test';
import assert from 'node:assert/strict';
import http2 from 'node:http2';
test('the server negotiates h2 over TLS', async () => {
const server = http2.createSecureServer({ key, cert });
server.on('stream', (stream) => { stream.respond({ ':status': 200 }); stream.end('ok'); });
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const client = await new Promise((resolve) => {
const c = http2.connect(`https://localhost:${port}`, { ca }, () => resolve(c));
});
assert.strictEqual(client.alpnProtocol, 'h2');
client.close();
server.close();
}); Those measurements ran on curl 8.7.1 with nghttp2 1.68.1 and Node v25.9.0, which sits on the Current line rather than an LTS one. Production should not sit there. Node.js 24, codename Krypton, is the Active LTS line, and the release policy is explicit: "Production applications should only use Active LTS or Maintenance LTS releases."
That is the whole migration: a server that negotiates h2, a client that proves it, and a written reason for every default you left alone. If the surrounding work turns out to be larger than an afternoon, whether that is a fleet of services or a proxy tier nobody has opened in two years, our Node.js software engineering team does this kind of migration as ordinary work, and we will tell you what the work looks like.