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.