Introducing QUIC and HTTP/3 Support in Node.js
For the past several years, I have been working on a native QUIC and HTTP/3
implementation for Node.js. It has been a long road, but a fuctional node:quic
module is now available behind the --experimental-quic flag in the Node.js
repository. This is the first in a series of posts where I will walk through the
architecture, concepts, and usage of the new module. In this post, we will cover
the basics: what QUIC is, why Node.js is adding native support, and how to get
started with a simple example.
This implementation is highly experimental and still under active development. The API is classified as Stability 1.0 (Early development) and is expected to change. If you try it out and run into issues or have feedback, please file an issue on the Node.js GitHub repository.
The implementation is not yet shipping in a stable release. To try it out, you
will need to checkout and build the latest code from the main branch, configure
the build with --experimental-quic, and run Node.js with the --experimental-quic
flag.
git clone https://github.com/nodejs/node
cd node
./configure --ninja --experimental-quic
make -j16
./node --experimental-quic my-app.mjsWhat is QUIC?
QUIC is a general-purpose transport protocol originally developed at Google and now standardized by the IETF as RFC 9000. It runs over UDP rather than TCP, and integrates TLS 1.3 encryption directly into the transport layer. That means every QUIC connection is encrypted by default -- there is no unencrypted mode.
The key properties that distinguish QUIC from TCP+TLS are:
-
Multiplexed streams without head-of-line blocking: A single QUIC connection can carry multiple independent streams of data. If a packet belonging to one stream is lost, only that stream is affected -- other streams on the same connection continue to make progress. With TCP, a single lost packet stalls the entire connection.
-
Faster connection establishment: QUIC combines the transport handshake and the TLS handshake into a single round trip. With 0-RTT session resumption, a returning client can send data in the very first packet, before the handshake completes.
-
Connection migration: QUIC connections are identified by connection IDs rather than the 4-tuple of source/destination IP and port. This means a connection can survive a network change (such as switching from Wi-Fi to cellular) without re-establishing the session.
-
Built-in flow control: QUIC has both connection-level and per-stream flow control, providing fine-grained backpressure without requiring application-layer workarounds.
HTTP/3 (RFC 9114) is the version
of HTTP that runs over QUIC. It replaces the TCP-based framing of HTTP/2 with
QUIC streams, inheriting all of the properties above. When you use the
node:quic module with the default ALPN of 'h3', you get an HTTP/3 session
powered by the nghttp3 library.
Why native support?
QUIC has properties that make a native implementation particularly compelling:
-
UDP handling: Node.js already has libuv's
uv_udp_tintegrated into the event loop. A native implementation can bind QUIC directly to the existing UDP infrastructure without the overhead of bridging through JavaScript for every packet. -
TLS integration: QUIC requires TLS 1.3, and the handshake is deeply interleaved with the transport protocol. A native implementation can use OpenSSL directly, sharing the same TLS infrastructure that
node:tlsandnode:httpsalready use. -
Performance: The packet processing path in QUIC is latency-sensitive. Having the core protocol logic in C++ (via the ngtcp2 library), with only the application-facing API exposed to JavaScript, avoids the overhead of crossing the JS/C++ boundary for every packet.
The node:quic implementation is built on top of four external dependencies:
- ngtcp2 -- the QUIC protocol state machine
- nghttp3 -- the HTTP/3 framing layer
- OpenSSL -- TLS 1.3 cryptographic operations
- libuv -- the event loop and UDP socket handling
The JavaScript API sits on top of a C++ layer that manages these dependencies
and exposes a small set of objects: QuicEndpoint, QuicSession, QuicStream,
and QuicError.
Getting started
The node:quic module is only available when Node.js is started with the
--experimental-quic flag:
node --experimental-quic my-app.mjsThe module is only accessible via the node: scheme:
import { listen, connect, QuicEndpoint } from 'node:quic';Because QUIC mandates TLS 1.3, every connection requires TLS credentials. For development and testing, you can generate a self-signed certificate with OpenSSL:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout key.pem -out cert.pem -days 365 -nodes \
-subj '/CN=localhost'Then load them in your application:
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');The architecture at a glance
Before we look at code, it helps to understand the four core objects in the
node:quic module and how they relate to each other:
-
A
QuicEndpointbinds a local UDP port. It can act as both a server (accepting inbound connections) and a client (initiating outbound connections). Multiple sessions can share a single endpoint. -
A
QuicSessionrepresents one QUIC connection between a local endpoint and a remote peer. Each session has its own TLS state, flow control windows, and congestion control. A session is created either by callingquic.connect()(client side) or by accepting an inbound connection viaquic.listen()(server side). -
A
QuicStreamis a single ordered byte stream within a session. Streams can be bidirectional (both sides read and write) or unidirectional (one side writes, the other reads). A session can carry many concurrent streams. -
A
QuicErroris anErrorsubclass that carries a numeric QUIC error code and a type ('transport'or'application'), making it straightforward to distinguish protocol-level errors from application-level errors.
The data flow looks roughly like this: the QuicEndpoint receives UDP packets
from the network, dispatches them to the appropriate QuicSession based on
connection ID, and the session in turn delivers data to the relevant
QuicStream. In the outbound direction, streams push data through the session's
send loop, which coalesces packets and sends them out via the endpoint's UDP
socket.
A simple echo server
Let us start with a minimal example: a QUIC server that accepts a connection, reads data from a bidirectional stream, and echoes it back.
echo-server.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen } from 'node:quic';
import { bytes } from 'stream/iter';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
const endpoint = await listen(async (session) => {
// Called once for each new inbound QUIC session.
session.onstream = async (stream) => {
// Read the full contents of the inbound stream.
const data = await bytes(stream);
// Echo it back and close the write side.
const writer = stream.writer;
writer.writeSync(data);
writer.endSync();
await stream.closed;
session.close();
};
}, {
// TLS configuration. The sni map associates server names with
// TLS credentials. The wildcard '*' matches any server name.
sni: { '*': { keys: [key], certs: [cert] } },
// The ALPN protocol to negotiate. Using a custom protocol name
// here rather than 'h3' so we get a raw QUIC session without
// the HTTP/3 framing layer.
alpn: ['echo-protocol'],
});
console.log('Echo server listening on', endpoint.address);There are a few things worth noting here:
-
The
listen()function takes a callback that is invoked once for each new inbound session. This callback is conceptually similar to the'connection'event on a TCP server. -
TLS credentials are configured via the
snioption, which maps server names to key/cert pairs. The wildcard'*'is a catch-all that matches any server name the client requests. -
The
alpnoption specifies which application-layer protocols the server supports. When set to['h3'](the default), the session uses the HTTP/3 framing layer. Here we use a custom protocol name to get a raw QUIC session. -
The
session.onstreamcallback fires whenever the remote peer opens a new stream. Thestreamobject supports async iteration for reading and has awriterproperty for writing. -
The
bytes()helper fromstream/itercollects the full contents of an async iterable into a singleUint8Array. This is a convenient way to read a stream to completion.
A simple echo client
Now the client side. We connect to the server, send a message on a bidirectional stream, and read the echo back:
echo-client.mjs
import { connect } from 'node:quic';
import { bytes } from 'stream/iter';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const session = await connect('localhost:0', {
// The server name for TLS SNI. Must match what the server expects.
servername: 'localhost',
// The ALPN protocol. Must match one of the server's offered protocols.
alpn: 'echo-protocol',
});
// Wait for the TLS handshake to complete.
const info = await session.opened;
console.log('Connected:', info.protocol, info.cipher);
// Create a bidirectional stream and send data in one shot.
const message = 'Hello from the QUIC client!';
const stream = await session.createBidirectionalStream({
body: encoder.encode(message),
});
// Read the server's echo.
const response = await bytes(stream);
console.log('Echo:', decoder.decode(response));
// Clean up.
await stream.closed;
await session.close();A few things to notice:
-
quic.connect()returns a promise that resolves to aQuicSession. By default, it creates a newQuicEndpointbound to a random local port. -
The
session.openedpromise resolves once the TLS handshake completes. The resolved value contains details about the negotiated connection: the ALPN protocol, cipher suite, server name, local and remote addresses, and whether 0-RTT early data was used. -
createBidirectionalStream()opens a new stream and optionally sends a body in one step. Thebodyoption accepts many types: strings,ArrayBuffer,Uint8Array,Blob,FileHandle, async iterables,ReadableStream, and evenPromisevalues. We will explore all of these in a later post. -
session.close()performs a graceful shutdown. It waits for all open streams to complete and then sends aCONNECTION_CLOSEframe to the peer with aNO_ERRORcode. This is in contrast tosession.destroy(), which tears down the session immediately.
Using connect() with an address string
The quic.connect() function accepts either a net.SocketAddress object or a
string. When using a string, the format is host:port:
// Connect to a specific host and port.
const session = await connect('192.168.1.100:4433', {
servername: 'myserver.example.com',
alpn: 'my-protocol',
});
// When using an IPv6 address, bracket notation is supported.
const session6 = await connect('[::1]:4433', {
servername: 'localhost',
alpn: 'my-protocol',
});Clean shutdown with Symbol.asyncDispose
Both QuicEndpoint and QuicSession implement Symbol.asyncDispose, which
means they work with the await using syntax for automatic cleanup:
dispose.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen, connect } from 'node:quic';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
{
await using endpoint = await listen(async (session) => {
session.onstream = async (stream) => {
// Process stream...
stream.writer.endSync();
};
}, {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['my-protocol'],
});
await using session = await connect(endpoint.address, {
servername: 'localhost',
alpn: 'my-protocol',
});
await session.opened;
// Do work with the session...
// When the block exits, session.close() and endpoint.close()
// are called automatically via Symbol.asyncDispose.
}Graceful close vs. immediate destroy
The node:quic API provides two ways to tear down a session or endpoint:
// Graceful close: waits for open streams to finish, then sends
// CONNECTION_CLOSE with NO_ERROR. Returns a promise.
await session.close();
// Immediate destroy: tears down immediately. If an error is
// provided, it is forwarded to the peer and to any pending
// promises (opened, closed, stream.closed, etc.).
session.destroy(new Error('something went wrong'));
// Destroy with an explicit QUIC error code:
session.destroy(new Error('app error'), {
code: 42n,
type: 'application',
});The same pattern applies to endpoints:
// Graceful: waits for all sessions to finish.
await endpoint.close();
// Immediate: all sessions are destroyed, pending ops rejected.
endpoint.destroy(new Error('shutting down'));What about the QuicEndpoint constructor?
In the examples above, quic.listen() and quic.connect() create endpoints
implicitly. You can also create an endpoint explicitly and pass it in:
explicit-endpoint.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen, connect, QuicEndpoint } from 'node:quic';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
// Create an endpoint bound to a specific port.
const endpoint = new QuicEndpoint({
address: { address: '0.0.0.0', port: 4433 },
});
// Use the same endpoint for both listening and connecting.
const serverEndpoint = await listen(async (session) => {
// Handle inbound sessions...
}, {
endpoint,
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['my-protocol'],
});
console.log('Listening on', endpoint.address);
// { address: '0.0.0.0', port: 4433, family: 'ipv4' }Explicit endpoints are useful when you need to control the local address/port, configure UDP buffer sizes, or share a single UDP socket across multiple sessions. We will look at endpoint configuration in detail in the next post.
The experimental caveat
I want to emphasize: this implementation is highly experimental. The API surface is large and will continue to evolve as we gather feedback from real-world usage. Things you should expect:
-
The API may (will) change: Method signatures, option names, and callback conventions could all shift in future releases. The
--experimental-quicflag is required and will remain required until the API stabilizes. -
Not all features are implemented: HTTP/3 server push, WebTransport, and higher-level HTTP semantics (automatic routing, content negotiation, etc.) are not yet available.
-
Performance is not yet optimized: The focus so far has been on correctness and API design. Performance tuning will come as the implementation matures.
If you try it out and hit issues, please file them at https://github.com/nodejs/node/issues -- feedback at this stage is invaluable.
What is next?
This post covered the basics. In the rest of this series, we will go much deeper:
-
Part 2: Endpoints, Sessions, and the QUIC Connection Lifecycle -- connection limits, address validation, TLS configuration, session events, endpoint reuse, and statistics.
-
Part 3: QUIC Streams: Sending and Receiving Data -- bidirectional and unidirectional streams, body sources, the Writer API, flow control, and backpressure.
-
Part 4: HTTP/3 Over QUIC in Node.js -- request/response with pseudo-headers, informational headers, trailing headers, stream priority, GOAWAY, and ORIGIN frames.
-
Part 5: Advanced QUIC: 0-RTT, Datagrams, SNI, and Observability -- session resumption, unreliable datagrams, virtual hosting, diagnostics channels, performance hooks, and qlog.
See you in Part 2.