WebTransport and Media over QUIC

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

WebTransport is an emerging API that lets a page open one HTTP/3 (QUIC) session to a server and then use many independent reliable streams and unreliable datagrams on it. This page was written against the W3C WebTransport Candidate Recommendation Snapshot of 2026-07-30, draft-ietf-webtrans-http3-16 and draft-ietf-webtrans-http2-15 (both Internet-Drafts in working-group last call, dated 2026-07-06), Chrome and Edge 97 or later, Firefox 114 or later, Safari 26.4 or later (Baseline 2026, newly available), @fails-components/webtransport 1.6.11, webtransport-go v0.13.0, aioquic 1.7.0, socket.io 4.8.4 and draft-ietf-moq-transport-22 (2026-10-01). Everything was checked on 2026-10-24. Because the IETF protocol drafts are not RFCs yet, wire details can still change and server libraries track specific draft numbers.

For the game in this section, Pixel Arena, WebTransport is the browser-to-server transport that most closely matches what game networking wants: small, unordered, loss-tolerant input and state datagrams next to reliable streams for joins, chat and map data. It is not yet a safe default, and WebSocket remains the transport to build first. Read Multiplayer Game Architecture and Data Channels for the alternatives it is compared with.

What WebTransport is

A WebTransport session is an HTTP/3 extended CONNECT request (with :protocol set to webtransport) that stays open and turns the QUIC connection into a multiplexed channel. On that one session the page can:

  • open any number of bidirectional streams and unidirectional streams, each reliable and ordered inside itself, and independent of the others, so a lost packet on one stream does not stall the rest (no TCP-style head-of-line blocking across streams);

  • send and receive datagrams, which are unreliable and unordered messages carried in QUIC DATAGRAM frames (RFC 9221) with HTTP datagrams (RFC 9297);

  • learn about connection loss quickly, because QUIC has its own loss detection and a connection migration story.

The pieces are layered:

Layer Specification

Browser API

W3C WebTransport, a Candidate Recommendation Snapshot since 2026-07-30 (an Editor’s Draft keeps moving)

Protocol over HTTP/3

draft-ietf-webtrans-http3 (-16), an Internet-Draft; HTTP/3 itself is RFC 9114

Protocol over HTTP/2

draft-ietf-webtrans-http2 (-15), an Internet-Draft, used when UDP does not work

Overview

draft-ietf-webtrans-overview

Transport

QUIC, RFC 9000; unreliable datagrams, RFC 9221; HTTP Datagrams, RFC 9297

A candidate recommendation snapshot is not a final W3C Recommendation, and the IETF documents are still drafts, so the page treats the whole stack as emerging. The API has changed before, for example datagram buffering moved from incomingHighWaterMark and outgoingHighWaterMark towards incomingMaxBufferedDatagrams and outgoingMaxBufferedDatagrams, and the writable side of datagrams gained createWritable().

How it differs from WebSocket and data channels

WebSocket WebRTC data channel WebTransport

Underlying protocol

TCP (and TLS), HTTP/1.1 upgrade, or HTTP/2 and HTTP/3 with RFC 8441 and RFC 9220

SCTP over DTLS over UDP, after ICE

QUIC over UDP, HTTP/3 session

Reliability

Always reliable and ordered

Per channel: reliable or partially reliable, ordered or not

Streams reliable, datagrams unreliable

Multiplexing

One ordered message stream

Many channels, one SCTP association

Many streams plus datagrams

Head-of-line blocking

Yes, one lost TCP segment stalls all messages

Per ordered channel

Per stream only

Client-server or peer-to-peer

Client-server

Peer-to-peer (needs signalling, ICE, TURN)

Client-server

Backpressure

bufferedAmount (or WebSocketStream in Chromium)

bufferedAmount and threshold events

Streams API backpressure

Custom headers

No

Not applicable

Not settable from the common browser API; use the URL, cookies or a first stream message

Fallback when UDP is blocked

Not needed

TURN over TLS

Application-level: use WebSocket

Maturity

RFC 6455, universal

W3C Recommendation, universal

CR Snapshot, newly baseline

Compared with WebSocket, WebTransport adds datagrams and independent streams. Compared with a WebRTC data channel it needs no signalling, no ICE and no SDP: the server is a normal HTTP/3 endpoint with a certificate, which is also why it cannot do peer-to-peer. Browsers apply neither WebSocket’s cookie quirks nor its handshake: a WebTransport session is a regular HTTP/3 request, so cookies and the Origin header are sent according to ordinary fetch rules, and the server still has to validate the Origin header and authenticate the session, see Security.

Browser support

The MDN compatibility data lists these first-supported versions (checked 2026-10-24):

Feature Chrome and Edge Firefox Safari

WebTransport, streams, datagrams

97

114

26.4

serverCertificateHashes option

100

125

26.4

congestionControl, allowPooling and requireUnreliable options

not listed

114

26.4

protocols option and protocol attribute

143

155

26.4

reliability attribute

157

114

26.4

datagrams.writable

97

114

not supported

datagrams.createWritable()

not listed

155

26.4

createSendGroup()

not listed

155

26.4

sendOrder stream option

not listed

119

26.4

MDN labels the API "Baseline 2026, newly available" (since March 2026). Three practical consequences follow. Safari supports WebTransport only from 26.4, so a page must keep working on iOS and macOS releases before that. Safari does not expose datagrams.writable, only createWritable(), so portable code calls createWritable() when it exists. And the options and attributes that are optional in the specification are not uniformly implemented, so feature-detect each one you depend on. Chromium-based browsers list reliability and WebTransport.supportsReliableOnly only from version 157.

The Socket.IO WebTransport guide still states that WebTransport is available in all major browsers but Safari. That is out of date: since Safari 26.4 the Socket.IO WebTransport transport can be used there too, as long as the server end is set up (see Socket.IO and WebTransport).

The API

Creating a session

const wt = new WebTransport('https://game.example.test:4433/arena', {
  congestionControl: 'low-latency',   // 'default' | 'throughput' | 'low-latency'
  allowPooling: false,                // do not share the connection with other origins
  requireUnreliable: true,            // fail instead of using a reliable-only HTTP/2 session
  protocols: ['pixel-arena.v1'],      // application-level protocol negotiation (newer browsers)
  serverCertificateHashes: []         // see "Certificates" below
})

The URL must use https and must name a port explicitly, and the page must be a secure context (it is also available in workers). The options are:

Option Meaning

congestionControl

A hint: throughput favours bulk transfer, low-latency favours latency, default leaves the choice to the browser. The congestionControl attribute reports the preference that was actually satisfied.

allowPooling

Allows the session to share an HTTP/3 connection with other WebTransport sessions to the same server. Off by default. Pooling shares congestion state, so games usually leave it off.

requireUnreliable

Rejects connections that can only offer reliable delivery, so a session never silently falls back to HTTP/2 streams without datagrams.

serverCertificateHashes

A list of { algorithm: 'sha-256', value } entries. The server’s certificate is accepted if its SHA-256 matches, without a CA. The certificate must be ECDSA with a P-256 curve and valid for at most 14 days. Meant for development and ephemeral servers.

protocols

Application protocols the client prefers; the server’s choice is exposed as protocol.

anticipatedConcurrentIncomingUnidirectionalStreams, anticipatedConcurrentIncomingBidirectionalStreams

Hints for the initial flow-control credit.

Lifecycle

  • ready is a promise that resolves when the session is usable and rejects when the connection could not be established. Always await it before using streams or datagrams.

  • closed resolves with { closeCode, reason } after a graceful close() by either side, and rejects with a WebTransportError on abrupt termination. wt.close({ closeCode: 4000, reason: 'bye' }) ends the session.

  • draining resolves when the session starts draining (the server is going away); finish current work and reconnect.

  • reliability is pending, reliable-only or supports-unreliable. WebTransport.supportsReliableOnly tells whether the browser can do HTTP/2 sessions.

  • getStats() returns connection statistics (round-trip time, datagram counters) and exportKeyingMaterial() exposes TLS-derived keying material; they are not available everywhere.

Streams

// Client opens a reliable, ordered, bidirectional stream.
const bidi = await wt.createBidirectionalStream({ sendOrder: 10 })
const writer = bidi.writable.getWriter()
await writer.write(new TextEncoder().encode('hello'))
await writer.close()

// A one-way stream: the client only writes.
const uni = await wt.createUnidirectionalStream()

// The server can open streams too; the client reads them.
const incoming = wt.incomingBidirectionalStreams.getReader()
const { value: serverStream } = await incoming.read()
const { value: unis } = await wt.incomingUnidirectionalStreams.getReader().read()

Each stream exposes standard Streams API objects, so backpressure comes for free: await writer.ready before large writes. Calling abort() on a writer or cancel() on a reader resets that one stream with an application error code, which is how you cancel one transfer without closing the session. Use streams for anything that must arrive: login, joins, chat, inventory changes and map downloads. sendOrder and createSendGroup() give a relative sending priority where supported.

Datagrams

const writer = (wt.datagrams.createWritable ? wt.datagrams.createWritable() : wt.datagrams.writable).getWriter()
const reader = wt.datagrams.readable.getReader()

await writer.write(new Uint8Array([1, 2, 3]))      // fire and forget
const { value, done } = await reader.read()        // one datagram per read
console.log(wt.datagrams.maxDatagramSize)          // about 1200 bytes in practice

A datagram is delivered at most once, may be lost, and may arrive reordered. The attributes that matter:

  • maxDatagramSize is the largest payload that fits in one QUIC packet after headers. In the test run for this page Chrome 155 reported 1211 bytes to the page and the server library 1213, so budget for roughly 1200 bytes. A larger datagram is not sent; the specification does not fragment, so split the data yourself or put it on a stream.

  • incomingMaxAge and outgoingMaxAge drop datagrams that wait longer than the given milliseconds in the queues. For a game, a queued input that is older than a tick is worse than none, so set an outgoingMaxAge of a few ticks.

  • incomingMaxBufferedDatagrams and outgoingMaxBufferedDatagrams (the older incomingHighWaterMark and outgoingHighWaterMark) bound the queues. When the outgoing queue is full the writer applies backpressure.

  • A datagramsReadableType: 'bytes' option allows bring-your-own-buffer reads.

Datagrams are congestion controlled by QUIC, so a sender that writes faster than the network allows will see queueing and drops locally rather than overloading the path.

Errors

WebTransportError extends DOMException and carries source (stream or session) and streamErrorCode. The common failures are:

  • ready rejects because UDP is blocked, the port is closed, the certificate is invalid, or the server does not speak HTTP/3 with WebTransport enabled. All look alike to the page, which is why a fallback is required.

  • serverCertificateHashes rejections: the certificate lifetime exceeds 14 days, the key is not ECDSA P-256, or the hash does not match the leaf certificate (hash the DER leaf, not the PEM text).

  • Opening the URL without an explicit port, or from an insecure context.

HTTP/3 requirement and the UDP fallback

WebTransport over HTTP/3 needs UDP (usually port 443 or a high port) to reach the server. Many corporate networks, hotel networks and some mobile paths block or throttle UDP, and some middleboxes rewrite it. A WebTransport URL has no TCP fallback of its own: if UDP fails, ready rejects (after a browser-dependent timeout). draft-ietf-webtrans-http2 defines a reliable-only variant over HTTP/2 that browsers may use when the API is created without requireUnreliable; it carries no real datagrams and is not implemented by every browser. Because nothing guarantees that either path works, a production client must:

  1. feature-detect ('WebTransport' in window);

  2. try WebTransport with a short deadline (race ready against a timer of 3 to 5 seconds);

  3. fall back to a WebSocket and remember the result for the rest of the session;

  4. keep the application protocol independent of the transport, exactly as in the example below, so a datagram becomes a binary WebSocket frame.

On the server, deploy HTTP/3 on a UDP port that the load balancer forwards (QUIC is connection-ID based, so a plain UDP balancer must hash the connection ID or pin the 4-tuple), and read Proxies, Load Balancers and Deployment. Most HTTP reverse proxies do not proxy WebTransport yet.

Certificates

A browser accepts a WebTransport server in two ways. A certificate from a public CA works like any HTTPS site. For development and short-lived servers (a game room server created on demand), the page can pin the certificate with serverCertificateHashes: the browser skips CA validation when the SHA-256 digest of the server’s certificate matches, on the condition that the certificate

  • uses an ECDSA key on the P-256 curve,

  • is valid for at most 14 days at the time of connection, and

  • is currently valid.

The pin is delivered out of band, for example in the matchmaking response that tells the client which room server to use. Never ship a long-lived self-signed certificate this way: the 14-day limit is exactly there to stop it.

#!/bin/sh
# Self-signed ECDSA P-256 certificate valid for 13 days (hash pinning needs <= 14 days).
set -e
openssl ecparam -name prime256v1 -genkey -noout -out key.pem
openssl req -new -x509 -key key.pem -out cert.pem -days 13 -subj "/CN=127.0.0.1" \
  -addext "subjectAltName=IP:127.0.0.1,DNS:localhost"

The ecparam route is deliberate: the LibreSSL shipped with macOS writes explicit curve parameters with req -newkey ec, and the quiche-based server library refuses that certificate.

Example: Pixel Arena input over datagrams

The scenario: a Pixel Arena player sends their movement input 30 times per second. Input is small, loses value quickly and is resent in the next packet anyway, so it goes in datagrams. A reliable stream carries the join message and the welcome reply. The server acknowledges each input with the current server tick, which the client can use for prediction and reconciliation, see Latency Compensation. If WebTransport is unavailable or fails, the same 10-byte messages go over a binary WebSocket and the game logic does not change.

The input datagram is 10 bytes and the acknowledgement 14 bytes:

input: version u8 | buttons u8 | seq u16 | moveX i8 | moveY i8 | clientMs u32
ack:   the 10 input bytes | serverTick u32     (all integers big endian)

Server (Node.js)

Install the dependencies and generate the certificate. The prebuilt QUIC binding lives in a separate package.

npm install @fails-components/webtransport @fails-components/webtransport-transport-http3-quiche ws
sh make-cert.sh
node server.mjs
// Pixel Arena input server: WebTransport datagrams with a WebSocket fallback.
import { createServer } from 'node:http'
import { readFileSync } from 'node:fs'
import { createHash, X509Certificate, randomBytes } from 'node:crypto'
import { Http3Server } from '@fails-components/webtransport'
import { WebSocketServer } from 'ws'

const WT_PORT = 4433
const HTTP_PORT = 8080
const cert = readFileSync('cert.pem', 'utf8')
const privKey = readFileSync('key.pem', 'utf8')
const certHash = createHash('sha256').update(new X509Certificate(cert).raw).digest('hex')

let tick = 0
setInterval(() => { tick = (tick + 1) >>> 0 }, 1000 / 30)

// Input datagram, 10 bytes: version u8 | buttons u8 | seq u16 | moveX i8 | moveY i8 | clientMs u32
// Ack datagram, 14 bytes: the same bytes followed by the server tick as u32 (big endian).
function ackFor(input) {
  if (input.byteLength !== 10 || input[0] !== 1) return null
  const out = new Uint8Array(14)
  out.set(input, 0)
  new DataView(out.buffer).setUint32(10, tick)
  return out
}

// 1. HTTP: static page plus the certificate hash for serverCertificateHashes (development only).
const http = createServer((req, res) => {
  const path = new URL(req.url, 'http://localhost').pathname
  if (path === '/config.json') {
    res.setHeader('content-type', 'application/json')
    res.end(JSON.stringify({ wtUrl: `https://127.0.0.1:${WT_PORT}/arena`, wsUrl: `ws://127.0.0.1:${HTTP_PORT}/arena`, certHash }))
  } else if (path === '/') {
    res.setHeader('content-type', 'text/html')
    res.end(readFileSync('index.html'))
  } else if (path === '/client.js') {
    res.setHeader('content-type', 'text/javascript')
    res.end(readFileSync('client.js'))
  } else {
    res.statusCode = 404
    res.end()
  }
})

// 2. WebSocket fallback: the same 10/14 byte messages in binary frames.
const wss = new WebSocketServer({ server: http, path: '/arena' })
wss.on('connection', (ws) => {
  ws.on('message', (data, isBinary) => {
    if (!isBinary) return
    const ack = ackFor(new Uint8Array(data))
    if (ack) ws.send(ack)
  })
})

// 3. WebTransport over HTTP/3.
const h3 = new Http3Server({ port: WT_PORT, host: '127.0.0.1', secret: randomBytes(16).toString('hex'), cert, privKey })

async function handleSession(session) {
  await session.ready
  // Reliable control stream: the client opens it and sends one JSON line, then we answer with its player id.
  const bidi = session.incomingBidirectionalStreams.getReader()
  const { value: stream } = await bidi.read()
  const reader = stream.readable.getReader()
  const { value } = await reader.read()
  const hello = JSON.parse(new TextDecoder().decode(value))
  const writer = stream.writable.getWriter()
  await writer.write(new TextEncoder().encode(JSON.stringify({ type: 'welcome', player: hello.name, maxDatagramSize: session.datagrams.maxDatagramSize }) + '\n'))
  await writer.close()
  // Unreliable path: acknowledge each input datagram.
  const dgWriter = session.datagrams.writable.getWriter()
  const dgReader = session.datagrams.readable.getReader()
  for (;;) {
    const { value: dg, done } = await dgReader.read()
    if (done) break
    const ack = ackFor(dg)
    if (ack) dgWriter.write(ack).catch(() => {})
  }
}

h3.startServer()
await h3.ready
const reader = h3.sessionStream('/arena').getReader()
http.listen(HTTP_PORT, '127.0.0.1', () => console.log(`http+ws on ${HTTP_PORT}, webtransport on udp ${WT_PORT}, cert sha256 ${certHash}`))
for (;;) {
  const { value: session, done } = await reader.read()
  if (done) break
  handleSession(session).catch((err) => console.error('session ended:', err.message))
}

Client (browser)

The page is served from http://127.0.0.1:8080, which is a secure context, and fetches the endpoints and the certificate hash from /config.json. The loop at the end sends 30 inputs and counts the acknowledgements.

<!doctype html>
<meta charset="utf-8">
<title>Pixel Arena transport test</title>
<pre id="log"></pre>
<script type="module" src="/client.js"></script>
// Pixel Arena input channel: WebTransport datagrams, falling back to a WebSocket.
const log = (m) => { document.getElementById('log').textContent += m + '\n'; console.log(m) }

function encodeInput(seq, moveX, moveY, buttons) {
  const buf = new ArrayBuffer(10)
  const v = new DataView(buf)
  v.setUint8(0, 1)
  v.setUint8(1, buttons)
  v.setUint16(2, seq)
  v.setInt8(4, moveX)
  v.setInt8(5, moveY)
  v.setUint32(6, performance.now() >>> 0)
  return new Uint8Array(buf)
}

function decodeAck(bytes) {
  const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
  return { seq: v.getUint16(2), clientMs: v.getUint32(6), serverTick: v.getUint32(10) }
}

async function openWebTransport(cfg, onAck) {
  if (!('WebTransport' in window)) throw new Error('WebTransport is not available')
  const options = {}
  if (cfg.certHash) {
    // Development only: pin a self-signed certificate (valid <= 14 days, ECDSA).
    const hash = Uint8Array.from(cfg.certHash.match(/../g).map((h) => parseInt(h, 16)))
    options.serverCertificateHashes = [{ algorithm: 'sha-256', value: hash }]
  }
  const wt = new WebTransport(cfg.wtUrl, options)
  await wt.ready
  // Reliable control stream.
  const stream = await wt.createBidirectionalStream()
  const w = stream.writable.getWriter()
  await w.write(new TextEncoder().encode(JSON.stringify({ type: 'hello', name: 'ada' })))
  await w.close()
  const r = stream.readable.getReader()
  const { value } = await r.read()
  log('control: ' + new TextDecoder().decode(value).trim())
  // Unreliable input path.
  // Safari exposes createWritable(); Chromium and Firefox still expose the older writable attribute.
  const dgOut = wt.datagrams.createWritable ? wt.datagrams.createWritable() : wt.datagrams.writable
  const dgWriter = dgOut.getWriter()
  const dgReader = wt.datagrams.readable.getReader()
  ;(async () => {
    for (;;) {
      const { value: dg, done } = await dgReader.read()
      if (done) return
      onAck(decodeAck(dg))
    }
  })()
  wt.closed.then(() => log('closed'), (e) => log('closed with error: ' + e.message))
  return { kind: 'webtransport', send: (bytes) => dgWriter.write(bytes), maxSize: wt.datagrams.maxDatagramSize, close: () => wt.close() }
}

function openWebSocket(cfg, onAck) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(cfg.wsUrl)
    ws.binaryType = 'arraybuffer'
    ws.onerror = () => reject(new Error('WebSocket failed'))
    ws.onmessage = (e) => onAck(decodeAck(new Uint8Array(e.data)))
    ws.onopen = () => resolve({ kind: 'websocket', send: (bytes) => ws.send(bytes), maxSize: 16384, close: () => ws.close() })
  })
}

const cfg = await (await fetch('/config.json')).json()
if (new URLSearchParams(location.search).has('forceWs')) delete cfg.wtUrl
let acks = 0
const onAck = (a) => { acks++; if (acks === 1 || acks === 30) log(`ack seq=${a.seq} serverTick=${a.serverTick}`) }
let conn
try {
  if (!cfg.wtUrl) throw new Error('forced fallback')
  conn = await openWebTransport(cfg, onAck)
} catch (err) {
  log('falling back to WebSocket: ' + err.message)
  conn = await openWebSocket(cfg, onAck)
}
log('transport=' + conn.kind + ' maxDatagramSize=' + conn.maxSize)
for (let seq = 0; seq < 30; seq++) {
  await conn.send(encodeInput(seq, 1, 0, 0))
  await new Promise((r) => setTimeout(r, 33))
}
await new Promise((r) => setTimeout(r, 300))
log(`DONE transport=${conn.kind} acks=${acks}`)
document.title = 'DONE ' + conn.kind + ' ' + acks
conn.close()

Running it in a browser

Start the server and open http://127.0.0.1:8080/ in Chrome. Because the example talks to 127.0.0.1 on a high UDP port, force QUIC for that origin so Chrome does not wait for an Alt-Svc upgrade, for example:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --origin-to-force-quic-on=127.0.0.1:4433 http://127.0.0.1:8080/

With the certificate pinned by serverCertificateHashes, no certificate flag is needed. If you instead point the page at a certificate the browser does not pin, add --ignore-certificate-errors-spki-list=<base64 SHA-256 of the public key> (development only). Opening /?forceWs skips WebTransport and exercises the fallback. In a test run with headless Chrome 155, the WebTransport page logged the welcome message, maxDatagramSize=1211 and 30 acknowledgements, and the forced fallback logged transport=websocket and 30 acknowledgements.

Servers

Stack Package Notes

Node.js

@fails-components/webtransport 1.6.11 (Node 20 or later)

A native binding to Google’s quiche. The HTTP/3 part is a separate prebuilt package for Windows, Linux and macOS. Server classes Http3Server, Http2Server and HttpServer; sessions behave like the browser WebTransport object. Node itself has no built-in WebTransport server.

Go

github.com/quic-go/webtransport-go v0.13.0

Built on quic-go, implements draft-ietf-webtrans-http3-16 and supports the latest two Go releases. The usual server of choice for Go game back ends.

Python

aioquic 1.7.0 (Python 3.11 or later)

A QUIC and HTTP/3 library. Its bundled http3_server.py example handles WebTransport sessions over ASGI-style messages but marks the session with an older draft header, so test it against the current browsers before relying on it.

Socket.IO

socket.io 4.8.4 with @fails-components/webtransport

See Socket.IO and WebTransport.

Other languages have QUIC stacks, but this section does not cover them. In all cases the server must advertise WebTransport support in its HTTP/3 settings and accept the extended CONNECT.

Socket.IO and WebTransport

Socket.IO 4.8 can use WebTransport as a third transport beside HTTP long polling and WebSocket. The client still starts with polling and upgrades to the best available transport. The server adds webtransport to transports and feeds sessions from an Http3Server into the engine. The guide’s server pattern, reduced to its core, is:

import { createServer } from 'node:https'
import { readFileSync } from 'node:fs'
import { Server } from 'socket.io'
import { Http3Server } from '@fails-components/webtransport'

const cert = readFileSync('cert.pem')
const key = readFileSync('key.pem')

const httpsServer = createServer({ cert, key })
const io = new Server(httpsServer, { transports: ['polling', 'websocket', 'webtransport'] })

const h3 = new Http3Server({ port: 3000, host: '0.0.0.0', secret: 'change-me', cert, privKey: key })
h3.startServer()

const sessions = h3.sessionStream('/socket.io/').getReader()
for (;;) {
  const { value: session, done } = await sessions.read()
  if (done) break
  io.engine.onWebTransportSession(session)
}

httpsServer.listen(3000)

On the client, io() needs no extra option when the server enables the transport; to test locally on Chromium the guide launches the browser with --origin-to-force-quic-on and a certificate hash flag. The guide’s statement that Safari is unsupported predates Safari 26.4. The default transports on the server is polling and WebSocket only, so a deployment that never enabled webtransport behaves as before. See Socket.IO (Node.js) for the rest of the Socket.IO server.

Media over QUIC

Media over QUIC (MoQ) is an IETF working-group effort to deliver live and on-demand media, from real-time conferencing to broadcast, over QUIC, using WebTransport in browsers. It is a draft, and nothing on this page is a recommendation to build on it today.

  • draft-ietf-moq-transport (MOQT), version 22, dated 2026-10-01, is an Active Internet-Draft in the moq working group. It defines publishers, relays and subscribers exchanging named tracks made of groups and objects, with publish/subscribe control messages on a bidirectional stream and media objects on unidirectional streams or datagrams.

  • draft-ietf-moq-msf (MOQT Streaming Format), version 01, dated 2026-06-02, is an Informational draft that defines the catalog, object packaging and adaptive-bitrate behaviour on top of MOQT; it replaces draft-ietf-moq-warp.

  • Both depend on draft-ietf-webtrans-http3, which is also still a draft.

The draft number changes frequently, relays and players built on different draft numbers do not interoperate, and browsers expose no MoQ API: a page implements MOQT in JavaScript or WebAssembly on top of WebTransport. For conferencing, the practical choices today are still WebRTC with an SFU, see Topologies: Mesh, SFU and MCU, and for one-to-many live streaming WHIP, WHEP and Live Streaming. Revisit MoQ once MOQT is an RFC and at least one relay you can operate has stable support.

When to choose it for Pixel Arena

Situation Choice

First version of the game, or a team that must support older Safari and every network

WebSocket, see The WebSocket Protocol

Server-authoritative game where players connect only to your server and input or snapshots can be dropped

WebTransport datagrams for input and snapshots, streams for joins and chat, WebSocket fallback

Peer-to-peer voice, video or a mesh of clients

WebRTC, see Data Channels

Already using Socket.IO

Enable the webtransport transport and keep the rest unchanged

Live broadcast of a match to many viewers

HLS or WHEP today, MoQ later

Choose WebTransport when all of the following hold: your player base is mostly on current browsers, you can run an HTTP/3 server with UDP reachable, your game logic already tolerates loss and reordering (sequence numbers and state snapshots rather than relying on the transport), and you will keep a WebSocket fallback anyway. The fallback is not optional, so the cost of WebTransport is a second transport to test, and the benefit is lower latency under loss, not new functionality. Measure before committing, with the methods in Latency Compensation.

Pitfalls and security

  • Treat it as a draft. Pin library versions, expect breaking changes when a draft number moves, and keep the application protocol separate from the transport.

  • No default CA-less trust. serverCertificateHashes is for ephemeral servers; do not ship a hash for a long-lived certificate, and never disable certificate checks in a client library to make a demo work.

  • Authenticate the session. The session is an HTTP/3 request: check Origin, cookies or a short-lived ticket in the URL, and rate limit streams and datagrams per session. Unauthenticated datagram floods are cheap for attackers.

  • Amplification and DoS. Datagrams need no handshake after the session starts; bound per-session buffers and close sessions that exceed the budget.

  • Datagrams are not private to your protocol. They are encrypted by QUIC, so the usual TLS guarantees apply, but there is no end-to-end encryption between clients.

  • Do not assume ordering or delivery of datagrams: include sequence numbers, and resend state that matters.

  • Idle timeouts. QUIC has its own idle timeout; send a keep-alive stream message or datagram, and see Connection Lifecycle, Heartbeats and Reconnection.

  • Large objects. Do not send snapshots above maxDatagramSize as datagrams; use a stream or split them.