Skip to main content

QUIC Endpoints, Sessions, and the QUIC Connection Lifecycle

Endpoints, Sessions, and the QUIC Connection Lifecycle

This is Part 2 in a series on the new node:quic module in Node.js. In Part 1, we covered what QUIC is and walked through a minimal echo server and client. In this post, we go deeper into the two foundational objects -- QuicEndpoint and QuicSession -- and examine how connections are established, configured, and torn down.

Reminder: the node:quic module is highly experimental (Stability 1.0). The APIs described here will change.

The QuicEndpoint

A QuicEndpoint is the object that owns a local UDP socket. Every QUIC session -- whether client or server -- is associated with an endpoint. The endpoint is responsible for:

  • Binding a local UDP address and port
  • Receiving inbound packets and dispatching them to the correct session
  • Sending outbound packets on behalf of sessions
  • Managing connection limits and address validation
  • Generating stateless reset tokens

Creating an endpoint

You can create an endpoint explicitly or let listen() / connect() create one implicitly:

endpoint-create.mjs

import { QuicEndpoint } from 'node:quic';
 
// Explicit: bind to a specific address and port.
const endpoint = new QuicEndpoint({
  address: { address: '0.0.0.0', port: 4433 },
});
 
// The address is available once the endpoint is bound.
// Binding happens lazily when the endpoint is first used
// (e.g. when listen() or connect() is called with it).

When listen() or connect() creates an endpoint implicitly, it binds to 0.0.0.0 on a random available port:

import { listen } from 'node:quic';
 
// The returned endpoint is bound to a random port.
const endpoint = await listen(onsession, options);
console.log(endpoint.address);
// { address: '0.0.0.0', port: 54321, family: 'ipv4' }

Endpoint options

The QuicEndpoint constructor accepts a number of configuration options:

endpoint-options.mjs

import { QuicEndpoint } from 'node:quic';
 
const endpoint = new QuicEndpoint({
  // Local bind address. Defaults to 0.0.0.0:0 (random port).
  address: { address: '127.0.0.1', port: 4433 },
 
  // Maximum concurrent connections from a single remote IP.
  // 0 means unlimited. Maximum value is 65535.
  maxConnectionsPerHost: 100,
 
  // Maximum total concurrent connections across all remote IPs.
  // 0 means unlimited. Maximum value is 65535.
  maxConnectionsTotal: 1000,
 
  // Require address validation via Retry packets before
  // accepting new connections. This adds a round trip but
  // helps mitigate amplification attacks.
  validateAddress: true,
 
  // Size of the LRU cache for validated addresses. Once a
  // peer's address has been validated, subsequent connections
  // from the same address skip the Retry flow.
  addressLRUSize: 100,
 
  // Seconds before an idle endpoint auto-destroys.
  // 0 means the endpoint lives until explicitly closed.
  idleTimeout: 0,
 
  // UDP socket buffer sizes.
  udpReceiveBufferSize: 2 * 1024 * 1024,
  udpSendBufferSize: 2 * 1024 * 1024,
 
  // UDP time-to-live.
  udpTTL: 64,
 
  // Disable stateless reset packets. Stateless resets allow
  // a peer to signal that it has lost state for a connection.
  disableStatelessReset: false,
 
  // Bind to IPv6 only (no dual-stack).
  ipv6Only: false,
 
  // Token and retry configuration.
  retryTokenExpiration: 10,   // seconds
  tokenExpiration: 3600,      // seconds
  maxRetries: 3,
  maxStatelessResetsPerHost: 3,
});

Most of these options have reasonable defaults. The ones you are most likely to configure in practice are address, maxConnectionsTotal, maxConnectionsPerHost, and validateAddress.

Connection limits

Connection limits are enforced at the endpoint level. When a limit is reached, new inbound connections are rejected with a CONNECTION_REFUSED error:

connection-limits.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');
 
// Allow only one concurrent connection.
const endpoint = new QuicEndpoint({
  maxConnectionsTotal: 1,
});
 
const serverEndpoint = await listen(async (session) => {
  await session.opened;
  // Keep the session open for a while.
  setTimeout(() => session.close(), 5000);
}, {
  endpoint,
  sni: { '*': { keys: [key], certs: [cert] } },
  alpn: ['my-protocol'],
});
 
// First connection succeeds.
const session1 = await connect(serverEndpoint.address, {
  servername: 'localhost',
  alpn: 'my-protocol',
});
await session1.opened;
console.log('First connection established');
 
// Second connection is rejected because the limit is 1.
const session2 = await connect(serverEndpoint.address, {
  servername: 'localhost',
  alpn: 'my-protocol',
});
try {
  await session2.opened;
} catch (err) {
  console.log('Second connection rejected:', err.code);
  // ERR_QUIC_TRANSPORT_ERROR
}

The maxConnectionsPerHost limit works similarly but is scoped to the number of concurrent connections from a single remote IP address.

Address validation

Address validation is a mechanism to verify that a client actually controls the source IP address it claims to be using. This is important because QUIC runs over UDP, which does not have the built-in address verification that TCP's three-way handshake provides. Without validation, an attacker could spoof a source address and use the server as an amplifier.

When validateAddress is true, the server responds to the initial client packet with a Retry packet that contains a token. The client must echo this token back in a new Initial packet, proving it can receive packets at the claimed address. This adds one round trip to the connection establishment:

address-validation.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');
 
const endpoint = new QuicEndpoint({
  validateAddress: true,
  addressLRUSize: 100,
});
 
const serverEndpoint = await listen(async (session) => {
  const info = await session.opened;
  console.log('Server: handshake complete');
  session.close();
}, {
  endpoint,
  sni: { '*': { keys: [key], certs: [cert] } },
  alpn: ['my-protocol'],
});
 
// The client does not need any special configuration.
// The Retry flow is handled transparently by the QUIC stack.
const session = await connect(serverEndpoint.address, {
  servername: 'localhost',
  alpn: 'my-protocol',
});
 
const info = await session.opened;
console.log('Client: connected with', info.protocol);
 
await session.closed;
await serverEndpoint.close();

Once a peer's address has been validated, the server caches the result in an LRU cache (sized by addressLRUSize). Subsequent connections from the same address skip the Retry flow.

Busy mode

An endpoint can be temporarily placed into "busy" mode, which rejects new inbound sessions without tearing down existing ones:

// Reject new connections temporarily.
endpoint.busy = true;
 
// Resume accepting connections.
endpoint.busy = false;

This is useful for implementing backpressure at the connection acceptance layer -- for example, when a server is under heavy load and wants to shed new connections while continuing to serve existing ones.

Endpoint statistics

The endpoint tracks a number of statistics that are available via the stats property:

const stats = endpoint.stats;
console.log({
  bytesReceived: stats.bytesReceived,
  bytesSent: stats.bytesSent,
  packetsReceived: stats.packetsReceived,
  packetsSent: stats.packetsSent,
  serverSessions: stats.serverSessions,
  clientSessions: stats.clientSessions,
  retryCount: stats.retryCount,
  statelessResetCount: stats.statelessResetCount,
});

All stat values are BigInts. The stats object also supports toJSON() and util.inspect() for easy logging and serialization.

The QuicSession

A QuicSession represents one QUIC connection. It wraps the ngtcp2 connection state machine and manages the TLS handshake, stream creation, flow control, congestion control, and connection lifecycle.

Client sessions

A client session is created by calling quic.connect():

import { connect } from 'node:quic';
 
const session = await connect('localhost:4433', {
  servername: 'localhost',
  alpn: 'my-protocol',
});

Server sessions

Server sessions are delivered via the callback passed to quic.listen():

import { listen } from 'node:quic';
 
const endpoint = await listen(async (session) => {
  // This callback is invoked for each new inbound session.
  const info = await session.opened;
  console.log('New session from', session.path.remote);
}, {
  sni: { '*': { keys: [key], certs: [cert] } },
  alpn: ['my-protocol'],
});

The handshake: session.opened

Every session begins with a TLS 1.3 handshake. The session.opened property is a promise that resolves once the handshake completes. The resolved value contains information about the negotiated connection:

session-opened.mjs

const session = await connect(serverAddress, {
  servername: 'localhost',
  alpn: 'my-protocol',
});
 
const info = await session.opened;
console.log({
  // The negotiated ALPN protocol.
  protocol: info.protocol,
 
  // The TLS cipher suite.
  cipher: info.cipher,
  cipherVersion: info.cipherVersion,
 
  // The SNI server name.
  servername: info.servername,
 
  // Local and remote socket addresses.
  localAddress: info.localAddress,
  remoteAddress: info.remoteAddress,
 
  // 0-RTT status.
  earlyDataAttempted: info.earlyDataAttempted,
  earlyDataAccepted: info.earlyDataAccepted,
 
  // TLS certificate validation result.
  validationErrorReason: info.validationErrorReason,
  validationErrorCode: info.validationErrorCode,
});

If the handshake fails (for example, due to a TLS error or ALPN mismatch), session.opened rejects:

try {
  const info = await session.opened;
} catch (err) {
  console.error('Handshake failed:', err.code, err.message);
  // For ALPN mismatch: ERR_QUIC_TRANSPORT_ERROR
}

Session path and certificates

Once the handshake is complete, the session exposes several read-only properties with information about the connection:

const info = await session.opened;
 
// The local and remote addresses.
console.log(session.path);
// { local: { address: '127.0.0.1', port: 54321 },
//   remote: { address: '127.0.0.1', port: 4433 } }
 
// The local TLS certificate (if configured).
console.log(session.certificate);
 
// The peer's TLS certificate.
console.log(session.peerCertificate);
 
// Ephemeral key info (client only, from the TLS handshake).
console.log(session.ephemeralKeyInfo);

These properties return undefined after the session has been destroyed.

ALPN negotiation

ALPN (Application-Layer Protocol Negotiation) is how the client and server agree on which protocol to use over the QUIC connection. The server offers a list of supported protocols; the client indicates which one it wants:

alpn.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');
 
// Server: offer three protocols.
const endpoint = await listen(async (session) => {
  const info = await session.opened;
  console.log('Server negotiated:', info.protocol);
  // 'proto-b' -- the one the client requested.
  session.close();
}, {
  sni: { '*': { keys: [key], certs: [cert] } },
  alpn: ['proto-a', 'proto-b', 'proto-c'],
});
 
// Client: request proto-b.
const session = await connect(endpoint.address, {
  servername: 'localhost',
  alpn: 'proto-b',
});
 
const info = await session.opened;
console.log('Client negotiated:', info.protocol);
// 'proto-b'
 
await session.close();
await endpoint.close();

If there is no matching protocol, the handshake fails. The default ALPN is 'h3', which activates the HTTP/3 application layer -- we will cover that in Part 4.

Congestion control

QUIC includes built-in congestion control. The node:quic implementation supports three algorithms, selectable per session:

// Reno (the simplest, AIMD-based algorithm).
const session1 = await connect(address, { cc: 'reno' });
 
// Cubic (the default, similar to TCP Cubic).
const session2 = await connect(address, { cc: 'cubic' });
 
// BBR (Bottleneck Bandwidth and Round-trip propagation time).
const session3 = await connect(address, { cc: 'bbr' });

The same option is available on the server side via listen() options. The congestion control algorithm affects how aggressively the session probes for available bandwidth and how it responds to packet loss.

Graceful close

Calling session.close() initiates a graceful shutdown:

// Default: send CONNECTION_CLOSE with NO_ERROR.
await session.close();
 
// With an explicit application error code and reason.
await session.close({
  code: 42n,
  type: 'application',
  reason: 'shutting down',
});
 
// With a transport error code.
await session.close({
  code: 0n,
  type: 'transport',
});

A graceful close waits for all open streams to finish before sending the CONNECTION_CLOSE frame. No new streams can be created after close() is called -- attempting to do so throws ERR_INVALID_STATE.

Immediate destroy

session.destroy() tears down the session immediately:

// Destroy without an error: session.closed resolves.
session.destroy();
 
// Destroy with an error: session.closed rejects.
session.destroy(new Error('something broke'));
 
// Destroy with an explicit QUIC error code sent to the peer.
session.destroy(new Error('app error'), {
  code: 99n,
  type: 'application',
  reason: 'custom error',
});

After destroy(), all pending promises (opened, closed, stream.closed) are rejected with the provided error (or resolved cleanly if no error was given). The session.destroyed property becomes true, and properties like session.path and session.endpoint return undefined or null.

The session.closed promise

The session.closed property is a promise that settles when the session is fully destroyed. If the session closes cleanly, it resolves. If it is destroyed with an error, it rejects:

// Clean close.
session.close();
await session.closed; // Resolves.
 
// Error destroy.
session.destroy(new Error('boom'));
try {
  await session.closed;
} catch (err) {
  console.error('Session ended with error:', err.message);
}

Error handling with onerror

The session.onerror callback is invoked when the session is destroyed with an error. It fires before the session is fully torn down, giving you an opportunity to log or react:

onerror.mjs

const session = await connect(address, {
  servername: 'localhost',
  alpn: 'my-protocol',
 
  // Set onerror via connect options to avoid missing early errors.
  onerror(err) {
    console.error('Session error:', err.code, err.message);
  },
});

You can also set it after creation:

session.onerror = (err) => {
  console.error('Session error:', err.message);
};

The onerror callback is called only when the session is destroyed with an error. It is not called when the session is destroyed without an error (e.g. a clean session.destroy() with no arguments).

If the onerror callback itself throws, the error is wrapped in a SuppressedError and delivered to process.on('uncaughtException').

Idle timeout and keep-alive

By default, QUIC sessions can remain idle indefinitely. You can configure an idle timeout via transport parameters, and optionally enable keep-alive pings:

keepalive.mjs

// Session closes automatically after 30 seconds of inactivity.
const session = await connect(address, {
  servername: 'localhost',
  alpn: 'my-protocol',
  transportParams: {
    maxIdleTimeout: 30000, // milliseconds
  },
});
 
// Alternatively, enable keep-alive to prevent idle timeout.
// This sends PING frames at the specified interval.
const session2 = await connect(address, {
  servername: 'localhost',
  alpn: 'my-protocol',
  keepAlive: 10000, // Send a PING every 10 seconds.
  transportParams: {
    maxIdleTimeout: 30000,
  },
});

Endpoint reuse and connection pooling

By default, quic.connect() reuses the most recently created endpoint for new outbound sessions. This means multiple client connections share a single UDP socket:

endpoint-reuse.mjs

import { connect } from 'node:quic';
 
const session1 = await connect('server-a.example.com:4433', {
  servername: 'server-a.example.com',
  alpn: 'my-protocol',
});
 
// By default, session2 reuses session1's endpoint.
const session2 = await connect('server-b.example.com:4433', {
  servername: 'server-b.example.com',
  alpn: 'my-protocol',
});
 
console.log(session1.endpoint === session2.endpoint);
// true -- same underlying UDP socket.
 
// To force a separate endpoint, set reuseEndpoint to false.
const session3 = await connect('server-c.example.com:4433', {
  servername: 'server-c.example.com',
  alpn: 'my-protocol',
  reuseEndpoint: false,
});
 
console.log(session1.endpoint === session3.endpoint);
// false -- different UDP sockets.

Endpoint reuse reduces the number of open UDP sockets and is generally what you want for client applications making multiple outbound connections. Set reuseEndpoint: false when you need isolation between connections (for example, in tests).

Session statistics

The session tracks detailed statistics about the connection's behavior:

session-stats.mjs

const info = await session.opened;
 
// Do some work with the session...
 
const stats = session.stats;
console.log({
  // Byte counters.
  bytesReceived: stats.bytesReceived,
  bytesSent: stats.bytesSent,
 
  // Stream counters.
  bidiInStreamCount: stats.bidiInStreamCount,
  bidiOutStreamCount: stats.bidiOutStreamCount,
  uniInStreamCount: stats.uniInStreamCount,
  uniOutStreamCount: stats.uniOutStreamCount,
 
  // Congestion control.
  cwnd: stats.cwnd,
  bytesInFlight: stats.bytesInFlight,
  maxBytesInFlight: stats.maxBytesInFlight,
  ssthresh: stats.ssthresh,
 
  // RTT measurements.
  latestRtt: stats.latestRtt,
  minRtt: stats.minRtt,
  smoothedRtt: stats.smoothedRtt,
  rttVar: stats.rttVar,
 
  // Datagram counters.
  datagramsSent: stats.datagramsSent,
  datagramsReceived: stats.datagramsReceived,
  datagramsAcknowledged: stats.datagramsAcknowledged,
  datagramsLost: stats.datagramsLost,
 
  // Timestamps.
  createdAt: stats.createdAt,
  handshakeCompletedAt: stats.handshakeCompletedAt,
  handshakeConfirmedAt: stats.handshakeConfirmedAt,
});

All stat values are BigInts. The RTT and congestion window values are especially useful for understanding network conditions and diagnosing performance issues.

The QuicError class

When a QUIC connection or stream encounters an error, it is represented by a QuicError -- an Error subclass that carries the numeric QUIC error code and the error type:

quic-error.mjs

import { QuicError } from 'node:quic';
 
// Create a transport error.
const transportError = new QuicError('connection timed out', {
  errorCode: 0x0an, // PROTOCOL_VIOLATION
  type: 'transport',
});
 
console.log(transportError.errorCode); // 10n
console.log(transportError.type);      // 'transport'
console.log(transportError.code);      // 'ERR_QUIC_STREAM_ABORTED'
 
// Create an application error.
const appError = new QuicError('request cancelled', {
  errorCode: 42n,
  type: 'application',
});
 
console.log(appError.errorCode); // 42n
console.log(appError.type);      // 'application'

The distinction between 'transport' and 'application' errors is important. Transport errors are defined by the QUIC specification itself (RFC 9000) and indicate protocol-level issues. Application errors are defined by the application protocol (e.g. HTTP/3 defines its own set of error codes) and indicate application-level issues.

Putting it together

Here is a more complete example that demonstrates several of the concepts from this post: explicit endpoint creation, connection limits, session events, and statistics:

complete.mjs

import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen, connect, QuicEndpoint } from 'node:quic';
import { bytes } from 'stream/iter';
 
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
 
// Create an endpoint with connection limits and address validation.
const serverEp = new QuicEndpoint({
  address: { address: '127.0.0.1', port: 4433 },
  maxConnectionsTotal: 100,
  maxConnectionsPerHost: 10,
  validateAddress: true,
});
 
const encoder = new TextEncoder();
const decoder = new TextDecoder();
 
const endpoint = await listen(async (session) => {
  const info = await session.opened;
  console.log(`New session: ${info.protocol} from ${session.path.remote.address}`);
 
  session.onerror = (err) => {
    console.error('Session error:', err.message);
  };
 
  session.onstream = async (stream) => {
    const data = await bytes(stream);
    console.log('Received:', decoder.decode(data));
 
    stream.writer.writeSync(encoder.encode('acknowledged'));
    stream.writer.endSync();
 
    await stream.closed;
  };
 
  // Log stats when the session closes.
  session.closed.then(() => {
    const stats = session.stats;
    console.log(`Session closed. Bytes: ${stats.bytesSent}/${stats.bytesReceived}`);
    console.log(`RTT: ${stats.smoothedRtt}ns, cwnd: ${stats.cwnd}`);
  }).catch(() => {});
}, {
  endpoint: serverEp,
  sni: { '*': { keys: [key], certs: [cert] } },
  alpn: ['my-protocol'],
});
 
console.log('Server listening on', endpoint.address);
 
// --- Client ---
const session = await connect('127.0.0.1:4433', {
  servername: 'localhost',
  alpn: 'my-protocol',
  cc: 'cubic',
});
 
await session.opened;
 
const stream = await session.createBidirectionalStream({
  body: encoder.encode('Hello from the client'),
});
 
const response = await bytes(stream);
console.log('Server response:', decoder.decode(response));
 
await stream.closed;
await session.close();
await endpoint.close();

What is next?

In Part 3, we will dive into QUIC streams in detail: bidirectional and unidirectional streams, the many types of body sources, the Writer API for incremental writing, flow control, backpressure, and stream lifecycle management.