Skip to main content

HTTP/3 Over QUIC in Node.js

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 onheaders callback is provided via listen() options rather than set on the stream directly. This is because the HTTP/3 application layer delivers headers as part of the stream setup, before onstream fires. Inside onheaders, this refers to the stream.

  • Headers on the client: The headers option on createBidirectionalStream() specifies the request pseudo-headers. The onheaders callback on the client fires when the server's response headers arrive.

  • The stream.headers property: After headers have been received, stream.headers returns the buffered initial headers object.

  • No explicit body on the GET request: When a client stream is created with headers but no body, the HTTP/3 layer automatically sets the END_STREAM flag 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:

  1. Server sends response headers with sendHeaders().
  2. Server writes the body.
  3. Server calls writer.endSync() to signal end of body.
  4. The onwanttrailers callback fires, asking the server to provide trailers.
  5. Server calls sendTrailers() with the trailing headers.
  6. Client receives the trailers via the ontrailers callback.

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); // 1n

We 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:http or frameworks like Express. The node:quic module 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.