Skip to main content

QUIC and HTTP/3 Come To Node.js (finally)

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.mjs

What 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_t integrated 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:tls and node:https already 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.mjs

The 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 QuicEndpoint binds 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 QuicSession represents 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 calling quic.connect() (client side) or by accepting an inbound connection via quic.listen() (server side).

  • A QuicStream is 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 QuicError is an Error subclass 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 sni option, which maps server names to key/cert pairs. The wildcard '*' is a catch-all that matches any server name the client requests.

  • The alpn option 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.onstream callback fires whenever the remote peer opens a new stream. The stream object supports async iteration for reading and has a writer property for writing.

  • The bytes() helper from stream/iter collects the full contents of an async iterable into a single Uint8Array. 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 a QuicSession. By default, it creates a new QuicEndpoint bound to a random local port.

  • The session.opened promise 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. The body option accepts many types: strings, ArrayBuffer, Uint8Array, Blob, FileHandle, async iterables, ReadableStream, and even Promise values. 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 a CONNECTION_CLOSE frame to the peer with a NO_ERROR code. This is in contrast to session.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-quic flag 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.