Advanced QUIC: 0-RTT, Datagrams, SNI, and Observability
This is Part 5, the final post in a series on the new node:quic module in
Node.js. In Part 4, we covered HTTP/3 over QUIC.
In this post, we explore the more advanced features of the implementation:
0-RTT session resumption, unreliable datagrams, SNI-based virtual hosting,
diagnostics channels, performance hooks, qlog, and other configuration knobs.
Reminder: the node:quic module is highly experimental (Stability 1.0). The
APIs described here may change in future releases.
0-RTT session resumption
One of QUIC's most compelling features is 0-RTT (zero round-trip time) session resumption. When a client has previously connected to a server and received a TLS session ticket, it can use that ticket on subsequent connections to send application data in the very first packet -- before the TLS handshake completes. This eliminates the handshake round trip for returning clients, dramatically reducing latency for the first request.
The 0-RTT flow has two parts:
-
First connection: The client connects normally. During the handshake, the server issues a session ticket (via the
onsessionticketcallback) and optionally a NEW_TOKEN (via theonnewtokencallback). The client saves both. -
Second connection: The client reconnects using the saved session ticket and token. Data sent before the handshake completes is encrypted with 0-RTT keys.
zero-rtt.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen, connect } from 'node:quic';
import { bytes } from 'stream/iter';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
const encoder = new TextEncoder();
// --- Server ---
const endpoint = await listen(async (session) => {
session.onstream = async (stream) => {
const data = await bytes(stream);
console.log(
'Server received:',
data.byteLength,
'bytes, early:', stream.early,
);
stream.writer.endSync();
await stream.closed;
session.close();
};
}, {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['zero-rtt-demo'],
});
// --- First connection: receive ticket and token ---
let savedTicket;
let savedToken;
const gotTicket = Promise.withResolvers();
const gotToken = Promise.withResolvers();
const session1 = await connect(endpoint.address, {
servername: 'localhost',
alpn: 'zero-rtt-demo',
// Fires when the server issues a TLS session ticket.
// The ticket is a Buffer containing opaque TLS state.
onsessionticket(ticket) {
console.log('Received session ticket:', ticket.byteLength, 'bytes');
savedTicket = ticket;
gotTicket.resolve();
},
// Fires when the server issues a NEW_TOKEN.
// The token allows skipping address validation (Retry) on reconnect.
onnewtoken(token) {
console.log('Received NEW_TOKEN:', token.byteLength, 'bytes');
savedToken = token;
gotToken.resolve();
},
});
const info1 = await session1.opened;
console.log('First connection - early data attempted:', info1.earlyDataAttempted);
// false -- this is the first connection.
// Wait for both the ticket and token to arrive.
await Promise.all([gotTicket.promise, gotToken.promise]);
// Send data to verify the connection works, then let it close.
const s1 = await session1.createBidirectionalStream({
body: encoder.encode('first connection'),
});
for await (const _ of s1) { /* drain */ }
await s1.closed;
await session1.closed;
// --- Second connection: 0-RTT with saved ticket and token ---
const session2 = await connect(endpoint.address, {
servername: 'localhost',
alpn: 'zero-rtt-demo',
sessionTicket: savedTicket,
token: savedToken,
});
// Send data BEFORE the handshake completes -- true 0-RTT.
const s2 = await session2.createBidirectionalStream({
body: encoder.encode('early data from 0-RTT!'),
});
// Now wait for the handshake to complete.
const info2 = await session2.opened;
console.log('Second connection - early data attempted:', info2.earlyDataAttempted);
// true
console.log('Second connection - early data accepted:', info2.earlyDataAccepted);
// true
for await (const _ of s2) { /* drain */ }
await s2.closed;
await session2.closed;
await endpoint.close();A few important details:
-
The
stream.earlyproperty on the server side tells you whether a stream was opened with 0-RTT data. This is important because 0-RTT data is susceptible to replay attacks -- an attacker who captures the initial packet can replay it. Applications must decide whether the data carried in 0-RTT streams is safe to replay (e.g. idempotent GET requests: yes; financial transactions: no). -
The server can reject 0-RTT. If the server's configuration has changed since the ticket was issued (e.g. reduced transport parameters, different HTTP/3 settings), 0-RTT data is rejected and the client falls back to a 1-RTT handshake. The client is notified via the
onearlyrejectedcallback. -
0-RTT can be disabled on either side:
// Server: reject all 0-RTT attempts.
const endpoint = await listen(onsession, {
enableEarlyData: false,
// ...
});
// Client: do not attempt 0-RTT even if a ticket is available.
const session = await connect(address, {
enableEarlyData: false,
sessionTicket: savedTicket,
});Unreliable datagrams
QUIC datagrams (RFC 9221) provide an unreliable, unordered message channel within a QUIC connection. Unlike streams, datagrams are not subject to flow control or retransmission -- if a datagram packet is lost, it is gone. This makes datagrams suitable for latency-sensitive data where retransmission would be counterproductive: real-time telemetry, game state updates, live media frames, etc.
Sending and receiving datagrams
datagram-basic.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');
const serverGot = Promise.withResolvers();
const endpoint = await listen(async (session) => {
await session.opened;
await serverGot.promise;
session.close();
}, {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['datagram-demo'],
transportParams: {
// Must set maxDatagramFrameSize > 0 to enable datagrams.
maxDatagramFrameSize: 1200,
},
// Fires when a datagram is received.
ondatagram(data) {
console.log('Server received datagram:', data);
// data is a Uint8Array.
serverGot.resolve();
},
});
const session = await connect(endpoint.address, {
servername: 'localhost',
alpn: 'datagram-demo',
transportParams: {
maxDatagramFrameSize: 1200,
},
});
await session.opened;
// Check the maximum datagram payload size the peer will accept.
console.log('Max datagram size:', session.maxDatagramSize);
// Send a datagram. Returns a BigInt ID for status tracking.
const id = await session.sendDatagram(new Uint8Array([1, 2, 3]));
console.log('Datagram sent with ID:', id); // 1n
await session.closed;
await endpoint.close();Datagram input types
sendDatagram() accepts several input types:
// Uint8Array / TypedArray / ArrayBuffer
await session.sendDatagram(new Uint8Array([1, 2, 3]));
// SharedArrayBuffer
const shared = new SharedArrayBuffer(4);
await session.sendDatagram(shared);
// DataView
await session.sendDatagram(new DataView(new ArrayBuffer(4)));
// String with encoding
await session.sendDatagram('48656c6c6f', 'hex');
await session.sendDatagram('SGVsbG8=', 'base64');
// UTF-8 string (multi-byte safe)
await session.sendDatagram('\u4f60\u597d');
// Promise that resolves to any of the above
await session.sendDatagram(Promise.resolve(new Uint8Array([1])));Datagram status tracking
The ondatagramstatus callback tracks whether datagrams were acknowledged by
the peer, lost, or abandoned before sending:
datagram-status.mjs
const session = await connect(endpoint.address, {
servername: 'localhost',
alpn: 'datagram-demo',
transportParams: { maxDatagramFrameSize: 1200 },
ondatagramstatus(id, status) {
console.log(`Datagram ${id}: ${status}`);
// status is 'acknowledged', 'lost', or 'abandoned'
},
});Datagram drop policies
When datagrams are queued faster than they can be sent, the session applies a drop policy to manage the queue:
const session = await connect(address, {
// Maximum number of pending datagrams in the queue.
maxPendingDatagrams: 128, // Default. Range: 0-65535.
// Which datagram to drop when the queue is full.
datagramDropPolicy: 'drop-oldest', // Default. Alternative: 'drop-newest'.
// Maximum number of send cycles before abandoning a datagram.
maxDatagramSendAttempts: 5, // Default. Range: 1-255.
});When a datagram is dropped from the queue, the ondatagramstatus callback
fires with status: 'abandoned'.
Echo server with datagrams
datagram-echo.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');
const clientGot = Promise.withResolvers();
const endpoint = await listen(async (session) => {
await session.opened;
}, {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['echo-dgram'],
transportParams: { maxDatagramFrameSize: 1200 },
// Echo datagrams back to the client.
ondatagram(data) {
// `this` is the session.
this.sendDatagram(data);
},
});
const session = await connect(endpoint.address, {
servername: 'localhost',
alpn: 'echo-dgram',
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram(data) {
console.log('Client received echo:', data);
clientGot.resolve();
},
});
await session.opened;
await session.sendDatagram(new Uint8Array([42, 43, 44]));
await clientGot.promise;
await session.close();
await endpoint.close();0-RTT datagrams
Datagrams can be sent as 0-RTT data on a resumed connection:
// Second connection with saved ticket and token.
const session = await connect(address, {
sessionTicket: savedTicket,
token: savedToken,
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram(data, early) {
// The `early` parameter is true if the datagram was received
// as 0-RTT data.
console.log('Datagram received, early:', early);
},
});
// Send a datagram before the handshake completes.
await session.sendDatagram(new Uint8Array([1, 2, 3]));SNI and virtual hosting
Server Name Indication (SNI) allows a single QUIC endpoint to serve multiple TLS identities based on the hostname the client requests. This is the QUIC equivalent of virtual hosting in HTTP/TLS.
Basic SNI configuration
sni-basic.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen, connect } from 'node:quic';
// Load two different certificate sets.
const key1 = createPrivateKey(readFileSync('site-a-key.pem'));
const cert1 = readFileSync('site-a-cert.pem');
const key2 = createPrivateKey(readFileSync('site-b-key.pem'));
const cert2 = readFileSync('site-b-cert.pem');
const endpoint = await listen(async (session) => {
const info = await session.opened;
console.log('Session for:', info.servername);
session.close();
}, {
sni: {
// Each hostname maps to its own TLS credentials.
'site-a.example.com': { keys: [key1], certs: [cert1] },
'site-b.example.com': { keys: [key2], certs: [cert2] },
// Wildcard: used when no specific hostname matches.
'*': { keys: [key1], certs: [cert1] },
},
alpn: ['my-protocol'],
});
// Client specifies which server name it wants.
const sessionA = await connect(endpoint.address, {
servername: 'site-a.example.com',
alpn: 'my-protocol',
});
const infoA = await sessionA.opened;
console.log('Connected to:', infoA.servername);
// 'site-a.example.com'
await sessionA.close();
const sessionB = await connect(endpoint.address, {
servername: 'site-b.example.com',
alpn: 'my-protocol',
});
const infoB = await sessionB.opened;
console.log('Connected to:', infoB.servername);
// 'site-b.example.com'
await sessionB.close();
await endpoint.close();Hot-swapping TLS identities with setSNIContexts()
One of the more interesting features is the ability to update TLS credentials at runtime without restarting the server. This is useful for certificate rotation, adding new virtual hosts, or responding to configuration changes:
sni-hotswap.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen, connect, QuicEndpoint } from 'node:quic';
const key1 = createPrivateKey(readFileSync('key-v1.pem'));
const cert1 = readFileSync('cert-v1.pem');
const key2 = createPrivateKey(readFileSync('key-v2.pem'));
const cert2 = readFileSync('cert-v2.pem');
const ep = new QuicEndpoint();
const endpoint = await listen(async (session) => {
await session.opened;
session.close();
}, {
endpoint: ep,
sni: { '*': { keys: [key1], certs: [cert1] } },
alpn: ['my-protocol'],
});
// First connection uses cert-v1.
const s1 = await connect(endpoint.address, {
servername: 'localhost',
alpn: 'my-protocol',
});
await s1.opened;
await s1.closed;
// Hot-swap to cert-v2. replace: true replaces the entire SNI map.
ep.setSNIContexts(
{ '*': { keys: [key2], certs: [cert2] } },
{ replace: true },
);
// Second connection uses cert-v2.
const s2 = await connect(endpoint.address, {
servername: 'localhost',
alpn: 'my-protocol',
});
await s2.opened;
await s2.closed;
// Merge new entries without replacing existing ones.
ep.setSNIContexts(
{ 'new-host.example.com': { keys: [key2], certs: [cert2] } },
{ replace: false }, // Default -- merges into existing map.
);
await endpoint.close();SNI mismatch
If the client requests a server name that does not match any entry in the SNI
map (and there is no wildcard), the TLS handshake fails with an
unrecognized_name alert:
const session = await connect(endpoint.address, {
servername: 'unknown-host.example.com',
alpn: 'my-protocol',
});
try {
await session.opened;
} catch (err) {
console.log('Handshake failed:', err.code);
// ERR_QUIC_TRANSPORT_ERROR
}Diagnostics channels
The node:quic module publishes events to node:diagnostics_channel for
observability. There are 25+ channels covering endpoint, session, and stream
lifecycle events. These channels fire synchronously during QUIC operations,
giving you a non-intrusive way to observe what is happening without modifying
application code.
Subscribing to channels
diagnostics.mjs
import dc from 'node:diagnostics_channel';
// Endpoint lifecycle.
dc.subscribe('quic.endpoint.created', (msg) => {
console.log('Endpoint created:', msg.endpoint.address);
});
dc.subscribe('quic.endpoint.listen', (msg) => {
console.log('Endpoint listening');
});
dc.subscribe('quic.endpoint.connect', (msg) => {
console.log('Outbound connection to:', msg.address);
});
dc.subscribe('quic.endpoint.closing', (msg) => {
console.log('Endpoint closing');
});
dc.subscribe('quic.endpoint.closed', (msg) => {
console.log('Endpoint closed, stats:', msg.stats);
});
// Session lifecycle.
dc.subscribe('quic.session.created.client', (msg) => {
console.log('Client session created');
});
dc.subscribe('quic.session.created.server', (msg) => {
console.log('Server session from:', msg.address);
});
dc.subscribe('quic.session.handshake', (msg) => {
console.log('Handshake complete:', msg.session);
});
dc.subscribe('quic.session.closing', (msg) => {
console.log('Session closing');
});
dc.subscribe('quic.session.closed', (msg) => {
console.log('Session closed, stats:', msg.stats);
});
dc.subscribe('quic.session.error', (msg) => {
console.error('Session error:', msg.error);
});
// Stream lifecycle.
dc.subscribe('quic.session.open.stream', (msg) => {
console.log('Opened stream:', msg.stream.id, msg.direction);
});
dc.subscribe('quic.session.received.stream', (msg) => {
console.log('Received stream from peer:', msg.stream.id, msg.direction);
});
dc.subscribe('quic.stream.closed', (msg) => {
console.log('Stream closed:', msg.stream.id);
});Additional channels
Beyond the lifecycle channels, there are channels for specific events:
// Datagram events.
dc.subscribe('quic.session.send.datagram', (msg) => {
console.log('Sending datagram, id:', msg.id, 'length:', msg.length);
});
dc.subscribe('quic.session.receive.datagram', (msg) => {
console.log('Received datagram, length:', msg.length, 'early:', msg.early);
});
dc.subscribe('quic.session.receive.datagram.status', (msg) => {
console.log('Datagram status:', msg.id, msg.status);
});
// TLS events.
dc.subscribe('quic.session.ticket', (msg) => {
console.log('Session ticket received');
});
dc.subscribe('quic.session.new.token', (msg) => {
console.log('NEW_TOKEN received from:', msg.address);
});
dc.subscribe('quic.session.update.key', (msg) => {
console.log('TLS key update');
});
// Path events.
dc.subscribe('quic.session.path.validation', (msg) => {
console.log('Path validation:', msg.result);
});
// HTTP/3 events.
dc.subscribe('quic.session.goaway', (msg) => {
console.log('GOAWAY, lastStreamId:', msg.lastStreamId);
});
dc.subscribe('quic.session.receive.origin', (msg) => {
console.log('ORIGIN:', msg.origins);
});
dc.subscribe('quic.session.version.negotiation', (msg) => {
console.log('Version negotiation:', msg.version);
});
// Busy mode.
dc.subscribe('quic.endpoint.busy.change', (msg) => {
console.log('Endpoint busy:', msg.busy);
});
// Stream-level events.
dc.subscribe('quic.stream.headers', (msg) => {
console.log('Headers:', msg.headers);
});
dc.subscribe('quic.stream.trailers', (msg) => {
console.log('Trailers:', msg.trailers);
});
dc.subscribe('quic.stream.info', (msg) => {
console.log('Informational headers:', msg.headers);
});
dc.subscribe('quic.stream.reset', (msg) => {
console.log('Stream reset:', msg.error);
});
dc.subscribe('quic.stream.blocked', (msg) => {
console.log('Stream blocked by flow control');
});Diagnostics channels are particularly useful for building monitoring dashboards, logging middleware, or APM integrations without coupling them to application logic.
Performance hooks
The node:quic module integrates with Node.js PerformanceObserver to emit
timing data for endpoints, sessions, and streams. This works only when a
PerformanceObserver is actively watching for 'quic' entries.
perf-hooks.mjs
import { PerformanceObserver } from 'node:perf_hooks';
import { listen, connect } from 'node:quic';
import { bytes } from 'stream/iter';
const entries = [];
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
entries.push(entry);
}
});
obs.observe({ entryTypes: ['quic'] });
// ... run QUIC operations ...
const endpoint = await listen(async (session) => {
session.onstream = async (stream) => {
await bytes(stream);
stream.writer.endSync();
await stream.closed;
session.close();
};
}, options);
const session = await connect(endpoint.address, clientOptions);
await session.opened;
const stream = await session.createBidirectionalStream({
body: encoder.encode('perf test'),
});
for await (const _ of stream) { /* drain */ }
await stream.closed;
await session.closed;
await endpoint.close();
// Inspect the collected entries.
obs.disconnect();
for (const entry of entries) {
console.log({
name: entry.name,
// 'QuicEndpoint', 'QuicSession', or 'QuicStream'
entryType: entry.entryType,
// Always 'quic'
startTime: entry.startTime,
duration: entry.duration,
// Session entries include handshake details.
handshake: entry.detail?.handshake,
// { protocol, earlyDataAttempted, earlyDataAccepted, ... }
// Stream entries include direction.
direction: entry.detail?.direction,
// 'bidi' or 'uni'
// All entries include stats.
stats: entry.detail?.stats,
});
}Performance entries are emitted when the corresponding object is destroyed. The
duration field measures the total lifetime of the endpoint, session, or
stream.
qlog
qlog is a structured logging format for QUIC and HTTP/3 connections, defined in a series of IETF drafts. It records detailed protocol events (packets sent and received, frames, loss detection, congestion control state changes) in a machine-readable JSON-SEQ format that can be analyzed with tools like qvis.
Enable qlog on a per-session basis:
qlog.mjs
import { writeFileSync } from 'node:fs';
import { listen, connect } from 'node:quic';
const clientChunks = [];
const endpoint = await listen(async (session) => {
await session.opened;
session.close();
}, {
...serverOptions,
qlog: true,
onqlog(data, fin) {
// `data` is a string containing JSON-SEQ records.
// `fin` is true on the last chunk (emitted during cleanup).
// Collect server-side qlog data here.
},
});
const session = await connect(endpoint.address, {
servername: 'localhost',
alpn: 'my-protocol',
qlog: true,
onqlog(data, fin) {
clientChunks.push(data);
if (fin) {
// All qlog data has been delivered. Write to file.
const qlogData = clientChunks.join('');
writeFileSync('client.sqlog', qlogData);
console.log('qlog written to client.sqlog');
}
},
});
await session.opened;
await session.closed;
await endpoint.close();The qlog output is in JSON-SEQ format (RFC 7464): each record is prefixed with
a Record Separator character (0x1e) and terminated by a newline. The first
record is a header with format metadata; subsequent records are individual
protocol events with a timestamp, event name, and event data.
The fin flag is important: the final qlog chunk is emitted during ngtcp2
connection destruction and is delivered via setImmediate. You must yield to
the event loop (or use the fin flag) to ensure you capture all qlog data.
Key update
TLS 1.3 supports key updates, which rotate the encryption keys used for a
connection without re-handshaking. The node:quic module exposes this via
session.updateKey():
const session = await connect(address, options);
await session.opened;
// Rotate encryption keys. The peer handles this transparently.
session.updateKey();
// Data continues to flow with the new keys.
const stream = await session.createBidirectionalStream({
body: encoder.encode('data with updated keys'),
});Key updates can be initiated by either side and are handled transparently by the QUIC stack. The peer does not need to take any action.
Congestion control selection
The node:quic module supports three congestion control algorithms. The choice
affects how aggressively the connection probes for bandwidth and how it responds
to loss:
// Reno: classic AIMD (Additive Increase, Multiplicative Decrease).
// Simple and predictable. Good baseline.
const session1 = await connect(address, { cc: 'reno' });
// Cubic: the default. Scales better than Reno for high-bandwidth,
// high-latency networks. Similar to TCP Cubic.
const session2 = await connect(address, { cc: 'cubic' });
// BBR: model-based congestion control that estimates bottleneck
// bandwidth and round-trip propagation time. Can achieve higher
// throughput on lossy networks but may be more aggressive.
const session3 = await connect(address, { cc: 'bbr' });You can verify the congestion window is active by inspecting session stats:
const stats = session.stats;
console.log('Congestion window:', stats.cwnd);
console.log('Bytes in flight:', stats.bytesInFlight);
console.log('Slow start threshold:', stats.ssthresh);QUIC version negotiation
The node:quic module supports both QUIC v1 (RFC 9000) and QUIC v2
(RFC 9369). You can specify the version explicitly:
// Use QUIC v1 (default).
const session1 = await connect(address, { version: 1 });
// Use QUIC v2.
const session2 = await connect(address, { version: 2 });If the client requests a version the server does not support, the server sends
a Version Negotiation packet. The client is notified via the
onversionnegotiation callback:
const session = await connect(address, {
onversionnegotiation(version, requestedVersions, supportedVersions) {
console.log('Version negotiation triggered');
console.log('Requested:', requestedVersions);
console.log('Supported:', supportedVersions);
},
});TLS key logging
For debugging TLS issues, you can enable key logging to capture the TLS key material used by the connection. This material can be used with Wireshark to decrypt captured QUIC packets.
import { createWriteStream } from 'node:fs';
const keylogFile = createWriteStream('tls-keys.log');
const session = await connect(address, {
keylog: true,
onkeylog(line) {
keylogFile.write(line + '\n');
},
});Set the SSLKEYLOGFILE environment variable in Wireshark to the path of the
key log file to decrypt the traffic.
Debug logging
For low-level debugging of the QUIC stack, the NODE_DEBUG_NATIVE environment
variable enables debug output from the C++ layer:
# Enable all QUIC debug logging.
NODE_DEBUG_NATIVE=QUIC node --experimental-quic app.mjs
# Enable logging from specific components.
NODE_DEBUG_NATIVE=QUIC,NGTCP2,NGHTTP3 node --experimental-quic app.mjsAdditionally, tlsTrace: true enables TLS handshake tracing:
const session = await connect(address, {
tlsTrace: true,
// TLS trace output goes to stderr.
});Wrapping up
Over these five posts, we have covered the full surface of the node:quic
module:
- Part 1: Introduction -- what QUIC is, the architecture, and a basic echo server.
- Part 2: Endpoints and Sessions -- connection management, TLS, ALPN, error handling, and statistics.
- Part 3: Streams -- body sources, the Writer API, flow control, backpressure, and stream lifecycle.
- Part 4: HTTP/3 -- request/response, headers, trailers, priority, GOAWAY, and ORIGIN.
- Part 5: Advanced (this post) -- 0-RTT, datagrams, SNI, diagnostics, performance, and qlog.
The implementation is the culmination of years of work, and there is still more to do. But the foundation is in place, and the API is ready for experimentation and feedback.
If you try it out, I would love to hear about your experience. File issues at
https://github.com/nodejs/node/issues, and remember to include
--experimental-quic in your bug reports. The more real-world feedback we get
at this stage, the better the final API will be.
node --experimental-quic your-app.mjs