HTTP/3 Over QUIC in Node.js
This is Part 4 in a series on the new node:quic module in Node.js. In
Part 3, we covered QUIC streams, body sources,
the Writer API, and flow control. In this post, we layer HTTP/3 on top of QUIC
and explore the request/response model, headers, trailers, stream priority,
GOAWAY, and ORIGIN frames.
Reminder: the node:quic module is highly experimental (Stability 1.0). The
APIs described here may change in future releases.
How HTTP/3 activates
The node:quic module has built-in HTTP/3 support via the nghttp3 library.
HTTP/3 is activated automatically when the negotiated ALPN protocol is 'h3'
-- which happens to be the default.
This means that if you call quic.listen() or quic.connect() without
specifying an alpn option, you get an HTTP/3 session:
import { listen, connect } from 'node:quic';
// Both of these use ALPN 'h3' by default, so the session
// is an HTTP/3 session with nghttp3 framing.
const endpoint = await listen(onsession, {
sni: { '*': { keys: [key], certs: [cert] } },
// alpn defaults to ['h3'] on the server.
});
const session = await connect(address, {
servername: 'localhost',
// alpn defaults to 'h3' on the client.
});When HTTP/3 is active, streams gain additional capabilities:
- Headers and trailers -- request and response headers using HTTP
pseudo-headers (
:method,:path,:status, etc.). - Informational responses -- 1xx status codes like 103 Early Hints.
- Stream priority -- per-stream priority signaling per RFC 9218.
- GOAWAY -- graceful shutdown signaling.
- ORIGIN frames -- origin advertisement per RFC 9412.
- HTTP/3 datagrams -- unreliable messages associated with a session.
When the ALPN is anything other than 'h3', these features are not available
and you get a raw QUIC session (as shown in the earlier posts in this series).
A basic HTTP/3 request and response
Let us start with a minimal HTTP/3 server and client. The key difference from the raw QUIC examples in earlier posts is the use of headers:
h3-server.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen } from 'node:quic';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
const encoder = new TextEncoder();
const endpoint = await listen(async (session) => {
session.onstream = async (stream) => {
await stream.closed;
session.close();
};
}, {
sni: { '*': { keys: [key], certs: [cert] } },
// Default ALPN is h3 -- omitted here to exercise the default.
// The onheaders callback fires when request headers arrive.
// `this` is bound to the stream.
onheaders(headers) {
console.log('Request:', headers[':method'], headers[':path']);
// Send response headers.
this.sendHeaders({
':status': '200',
'content-type': 'text/plain',
});
// Write the response body and close the write side.
const writer = this.writer;
writer.writeSync(encoder.encode('Hello from HTTP/3!'));
writer.endSync();
},
});
console.log('H3 server listening on', endpoint.address);h3-client.mjs
import { connect } from 'node:quic';
import { bytes } from 'stream/iter';
const decoder = new TextDecoder();
const session = await connect('localhost:4433', {
servername: 'localhost',
// Default ALPN is h3.
});
const info = await session.opened;
console.log('Protocol:', info.protocol); // 'h3'
const headersReceived = Promise.withResolvers();
const stream = await session.createBidirectionalStream({
// HTTP/3 request pseudo-headers.
headers: {
':method': 'GET',
':path': '/index.html',
':scheme': 'https',
':authority': 'localhost',
},
// Callback fires when the response headers arrive.
onheaders(headers) {
console.log('Status:', headers[':status']);
console.log('Content-Type:', headers['content-type']);
headersReceived.resolve();
},
});
await headersReceived.promise;
// Read the response body.
const body = await bytes(stream);
console.log('Body:', decoder.decode(body));
// The stream.headers property returns the buffered response headers.
console.log('Buffered headers:', stream.headers);
await stream.closed;
await session.close();Several things are different from the raw QUIC examples:
-
Headers on the server: The
onheaderscallback is provided vialisten()options rather than set on the stream directly. This is because the HTTP/3 application layer delivers headers as part of the stream setup, beforeonstreamfires. Insideonheaders,thisrefers to the stream. -
Headers on the client: The
headersoption oncreateBidirectionalStream()specifies the request pseudo-headers. Theonheaderscallback on the client fires when the server's response headers arrive. -
The
stream.headersproperty: After headers have been received,stream.headersreturns the buffered initial headers object. -
No explicit body on the GET request: When a client stream is created with
headersbut nobody, the HTTP/3 layer automatically sets theEND_STREAMflag on the HEADERS frame, signaling that the request has no body.
POST requests with a body
Sending a request with a body is straightforward -- provide both headers and
body:
h3-post.mjs
import { connect } from 'node:quic';
import { bytes } from 'stream/iter';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const session = await connect('localhost:4433', {
servername: 'localhost',
});
await session.opened;
const stream = await session.createBidirectionalStream({
headers: {
':method': 'POST',
':path': '/api/data',
':scheme': 'https',
':authority': 'localhost',
'content-type': 'application/json',
},
body: encoder.encode(JSON.stringify({ message: 'hello' })),
onheaders(headers) {
console.log('Response status:', headers[':status']);
},
});
const response = await bytes(stream);
console.log('Response:', decoder.decode(response));
await stream.closed;
await session.close();On the server side, reading the request body works the same as reading any QUIC stream:
// In the server's onheaders callback:
onheaders(headers) {
if (headers[':method'] === 'POST') {
// Read the request body from `this` (the stream).
// We need to do this asynchronously.
this.handlePost = async () => {
const body = await bytes(this);
const data = JSON.parse(decoder.decode(body));
console.log('Received:', data);
this.sendHeaders({ ':status': '200' });
this.writer.writeSync(encoder.encode('OK'));
this.writer.endSync();
};
this.handlePost();
}
},You can also send a file as the request body using a FileHandle:
import { open } from 'node:fs/promises';
const file = await open('/path/to/upload.bin', 'r');
const stream = await session.createBidirectionalStream({
headers: {
':method': 'POST',
':path': '/upload',
':scheme': 'https',
':authority': 'localhost',
},
body: file, // Reads directly from the file descriptor.
});Informational headers (1xx responses)
HTTP/3 supports informational (1xx) responses, which are sent before the final response. The most common use case is 103 Early Hints, which allows a server to send preload hints to the client while the final response is being prepared.
The server uses sendInformationalHeaders() to send 1xx responses, and the
client receives them via the oninfo callback:
h3-early-hints.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();
const decoder = new TextDecoder();
// --- Server ---
const endpoint = await listen(async (session) => {
session.onstream = async (stream) => {
await stream.closed;
session.close();
};
}, {
sni: { '*': { keys: [key], certs: [cert] } },
onheaders(headers) {
// Send 103 Early Hints before the final response.
this.sendInformationalHeaders({
':status': '103',
'link': '</style.css>; rel=preload; as=style',
});
// Send the final response.
this.sendHeaders({
':status': '200',
'content-type': 'text/html',
});
this.writer.writeSync(encoder.encode('<html>...</html>'));
this.writer.endSync();
},
});
// --- Client ---
const session = await connect(endpoint.address, {
servername: 'localhost',
});
await session.opened;
const stream = await session.createBidirectionalStream({
headers: {
':method': 'GET',
':path': '/page',
':scheme': 'https',
':authority': 'localhost',
},
// Fires for each 1xx informational response.
oninfo(headers) {
console.log('Informational:', headers[':status']);
console.log('Link:', headers.link);
// '103'
// '</style.css>; rel=preload; as=style'
},
// Fires for the final response headers.
onheaders(headers) {
console.log('Final status:', headers[':status']);
// '200'
},
});
const body = await bytes(stream);
console.log('Body:', decoder.decode(body));
// stream.headers returns the final headers, not the 1xx headers.
console.log(stream.headers[':status']); // '200'
await stream.closed;
await session.close();
await endpoint.close();The oninfo callback fires once for each informational response. The onheaders
callback fires only for the final response. The stream.headers property always
returns the final (initial) headers, never the informational ones.
Trailing headers
HTTP/3 supports trailing headers (trailers) that are sent after the response body. Trailers are commonly used for checksums, digests, or other metadata that can only be computed after the full body has been generated.
The flow is:
- Server sends response headers with
sendHeaders(). - Server writes the body.
- Server calls
writer.endSync()to signal end of body. - The
onwanttrailerscallback fires, asking the server to provide trailers. - Server calls
sendTrailers()with the trailing headers. - Client receives the trailers via the
ontrailerscallback.
h3-trailers.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();
const decoder = new TextDecoder();
// --- Server ---
const endpoint = await listen(async (session) => {
session.onstream = async (stream) => {
await stream.closed;
session.close();
};
}, {
sni: { '*': { keys: [key], certs: [cert] } },
onheaders(headers) {
this.sendHeaders({
':status': '200',
'content-type': 'text/plain',
});
const body = 'This response has trailers';
this.writer.writeSync(encoder.encode(body));
this.writer.endSync();
},
// Fires after the body is fully sent. Provide trailers here.
onwanttrailers() {
this.sendTrailers({
'x-checksum': 'sha256=abc123def456',
'x-request-id': '42',
});
},
});
// --- Client ---
const session = await connect(endpoint.address, {
servername: 'localhost',
});
await session.opened;
const trailersReceived = Promise.withResolvers();
const stream = await session.createBidirectionalStream({
headers: {
':method': 'GET',
':path': '/with-trailers',
':scheme': 'https',
':authority': 'localhost',
},
onheaders(headers) {
console.log('Status:', headers[':status']);
},
// Fires after the body, when trailers arrive.
ontrailers(trailers) {
console.log('Trailers:', trailers);
// { 'x-checksum': 'sha256=abc123def456', 'x-request-id': '42' }
trailersReceived.resolve();
},
});
const body = await bytes(stream);
console.log('Body:', decoder.decode(body));
await trailersReceived.promise;
// stream.headers still returns the initial headers, not trailers.
console.log(stream.headers[':status']); // '200'
await stream.closed;
await session.close();
await endpoint.close();Stream priority
HTTP/3 supports per-stream priority signaling as defined in RFC 9218 (Extensible Prioritization Scheme for HTTP). Priorities help the server decide how to allocate bandwidth when multiple streams are active concurrently.
Each stream has a priority level ('high', 'default', or 'low') and an
incremental flag (whether data from this stream should be interleaved with
data from other streams of the same priority).
h3-priority.mjs
import { connect } from 'node:quic';
import { bytes } from 'stream/iter';
const session = await connect('localhost:4433', {
servername: 'localhost',
});
await session.opened;
// Set priority at creation time.
const highPriority = await session.createBidirectionalStream({
headers: {
':method': 'GET',
':path': '/critical.css',
':scheme': 'https',
':authority': 'localhost',
},
priority: 'high',
incremental: false,
onheaders(headers) { /* ... */ },
});
console.log(highPriority.priority);
// { level: 'high', incremental: false }
// Low priority, incremental (interleave with same-level streams).
const lowPriority = await session.createBidirectionalStream({
headers: {
':method': 'GET',
':path': '/analytics.js',
':scheme': 'https',
':authority': 'localhost',
},
priority: 'low',
incremental: true,
onheaders(headers) { /* ... */ },
});
console.log(lowPriority.priority);
// { level: 'low', incremental: true }
// Default priority (when not specified).
const defaultStream = await session.createBidirectionalStream({
headers: {
':method': 'GET',
':path': '/page.html',
':scheme': 'https',
':authority': 'localhost',
},
onheaders(headers) { /* ... */ },
});
console.log(defaultStream.priority);
// { level: 'default', incremental: false }Changing priority after creation
You can change a stream's priority at any time with setPriority(). This
sends a PRIORITY_UPDATE frame to the peer:
const stream = await session.createBidirectionalStream({
headers: { /* ... */ },
onheaders(headers) { /* ... */ },
});
// Initially default.
console.log(stream.priority);
// { level: 'default', incremental: false }
// Upgrade to high priority.
stream.setPriority({ level: 'high' });
console.log(stream.priority);
// { level: 'high', incremental: false }
// Change to low and incremental.
stream.setPriority({ level: 'low', incremental: true });
console.log(stream.priority);
// { level: 'low', incremental: true }On the server side, stream.priority reflects the client's most recent
priority update.
GOAWAY and graceful shutdown
When an HTTP/3 server calls session.close(), it sends a GOAWAY frame to the
client before sending CONNECTION_CLOSE. The GOAWAY frame tells the client
that the server is shutting down and will not accept new requests, but existing
requests will be completed.
The client receives the GOAWAY via the ongoaway callback:
h3-goaway.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();
const decoder = new TextDecoder();
let serverSession;
const endpoint = await listen(async (session) => {
serverSession = session;
session.onstream = async (stream) => {
await stream.closed;
};
}, {
sni: { '*': { keys: [key], certs: [cert] } },
onheaders(headers) {
this.sendHeaders({ ':status': '200' });
this.writer.writeSync(encoder.encode('response'));
this.writer.endSync();
},
});
const goawayReceived = Promise.withResolvers();
const session = await connect(endpoint.address, {
servername: 'localhost',
ongoaway(lastStreamId) {
console.log('GOAWAY received, lastStreamId:', lastStreamId);
goawayReceived.resolve();
},
});
await session.opened;
// Open a stream.
const stream = await session.createBidirectionalStream({
headers: {
':method': 'GET',
':path': '/page',
':scheme': 'https',
':authority': 'localhost',
},
onheaders(headers) {},
});
const body = await bytes(stream);
await stream.closed;
// Server initiates graceful close -- sends GOAWAY.
serverSession.close();
await goawayReceived.promise;
// After GOAWAY, new streams fail.
try {
await session.createBidirectionalStream({
headers: {
':method': 'GET',
':path': '/new-request',
':scheme': 'https',
':authority': 'localhost',
},
});
} catch (err) {
console.log('New stream rejected:', err.code);
// ERR_INVALID_STATE
}
// But streams that were already open before GOAWAY complete normally.
await session.close();
await endpoint.close();The GOAWAY semantics are specific to HTTP/3. If you are using a custom ALPN
(not 'h3'), session.close() sends a CONNECTION_CLOSE frame directly
without a GOAWAY, and the ongoaway callback is never fired.
ORIGIN frames
HTTP/3 supports ORIGIN frames (RFC 9412), which allow a server to advertise which origins it is authoritative for. This is useful for connection coalescing -- a client can reuse a single HTTP/3 connection for requests to multiple origins if the server advertises authority for those origins.
The client receives ORIGIN frames via the onorigin callback:
h3-origin.mjs
const session = await connect('cdn.example.com:443', {
servername: 'cdn.example.com',
onorigin(origins) {
console.log('Server is authoritative for:', origins);
// ['https://static.example.com', 'https://api.example.com']
},
});HTTP/3 datagrams
HTTP/3 datagrams (RFC 9297) allow sending unreliable, unordered messages within an HTTP/3 session. They are useful for latency-sensitive data where reliability is not required (e.g. real-time telemetry, live video frames).
Datagrams must be explicitly enabled via the application.enableDatagrams
option:
h3-datagrams.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 endpoint = await listen(async (session) => {
// Handle session...
}, {
sni: { '*': { keys: [key], certs: [cert] } },
application: {
enableDatagrams: true,
},
transportParams: {
maxDatagramFrameSize: 1200,
},
ondatagram(data) {
console.log('Server received datagram:', data.byteLength, 'bytes');
},
});
const session = await connect(endpoint.address, {
servername: 'localhost',
application: {
enableDatagrams: true,
},
transportParams: {
maxDatagramFrameSize: 1200,
},
});
await session.opened;
// Send an unreliable datagram.
const id = await session.sendDatagram(new Uint8Array([1, 2, 3]));
console.log('Datagram sent with id:', id); // 1nWe will cover datagrams in more detail in Part 5.
HTTP/3 settings
The HTTP/3 layer exposes several settings that can be configured via the
application option:
const endpoint = await listen(onsession, {
sni: { '*': { keys: [key], certs: [cert] } },
application: {
// Maximum number of header key-value pairs in a single
// header block. Default varies by implementation.
maxHeaderPairs: 128,
// Maximum total size of a header block in bytes.
maxHeaderLength: 16384,
// Maximum size of a HEADERS/TRAILERS section.
maxFieldSectionSize: 65536,
// QPACK dynamic table capacity.
qpackMaxDTableCapacity: 4096,
// QPACK encoder max dynamic table capacity.
qpackEncoderMaxDTableCapacity: 4096,
// Maximum number of streams that can be blocked waiting
// for QPACK decoder instructions.
qpackBlockedStreams: 16,
// Enable the extended CONNECT protocol (RFC 9220).
enableConnectProtocol: false,
// Enable HTTP/3 datagrams (RFC 9297).
enableDatagrams: false,
},
});A complete HTTP/3 server
Putting it all together, here is a more complete HTTP/3 server that handles multiple request types:
h3-complete-server.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen } from 'node:quic';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
const encoder = new TextEncoder();
const endpoint = await listen(async (session) => {
const info = await session.opened;
console.log(`New H3 session from ${session.path.remote.address}`);
session.onerror = (err) => {
console.error('Session error:', err.message);
};
session.onstream = async (stream) => {
await stream.closed;
};
}, {
sni: { '*': { keys: [key], certs: [cert] } },
onheaders(headers) {
const method = headers[':method'];
const path = headers[':path'];
console.log(`${method} ${path}`);
if (method === 'GET' && path === '/') {
this.sendHeaders({
':status': '200',
'content-type': 'text/html',
});
this.writer.writeSync(encoder.encode(`
<!doctype html>
<html>
<body><h1>Hello from HTTP/3!</h1></body>
</html>
`));
this.writer.endSync();
} else if (method === 'GET' && path === '/api/status') {
this.sendHeaders({
':status': '200',
'content-type': 'application/json',
});
this.writer.writeSync(encoder.encode(
JSON.stringify({ status: 'ok', protocol: 'h3' }),
));
this.writer.endSync();
} else if (method === 'POST' && path === '/api/echo') {
// Read the request body and echo it back.
const chunks = [];
const stream = this;
// Use the onheaders context to kick off async body reading.
(async () => {
const { bytes } = await import('stream/iter');
const body = await bytes(stream);
stream.sendHeaders({
':status': '200',
'content-type': 'application/octet-stream',
});
stream.writer.writeSync(body);
stream.writer.endSync();
})();
} else {
this.sendHeaders({ ':status': '404' });
this.writer.writeSync(encoder.encode('Not Found'));
this.writer.endSync();
}
},
});
console.log('HTTP/3 server listening on', endpoint.address);What is NOT implemented
It is worth being explicit about what the HTTP/3 layer does not provide:
-
Server push: HTTP/3 defines a server push mechanism, but it is not implemented in
node:quic(and likely won't ever be). -
WebTransport: While the QUIC transport could support WebTransport, the higher-level WebTransport API is not implemented.
-
High-level HTTP semantics: There is no built-in routing, content negotiation, cookie handling, redirect following, or any of the conveniences you would expect from
node:httpor frameworks like Express. Thenode:quicmodule provides the transport and framing layers; building a full HTTP/3 server framework on top of it is left as an exercise. -
Automatic content encoding: Compression (gzip, brotli, zstd) is not handled by the QUIC layer. You would need to implement it at the application level.
These gaps are intentional at this stage. The focus is on getting the transport and framing layers right before adding higher-level abstractions.
What is next?
In the final post, Part 5, we will cover advanced QUIC features: 0-RTT session resumption, unreliable datagrams in depth, SNI and virtual hosting, diagnostics channels, performance hooks, and qlog for debugging.