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 |
|
Protocol over HTTP/2 |
|
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 |
|
|
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 |
|---|---|---|---|
|
97 |
114 |
26.4 |
|
100 |
125 |
26.4 |
|
not listed |
114 |
26.4 |
|
143 |
155 |
26.4 |
|
157 |
114 |
26.4 |
|
97 |
114 |
not supported |
|
not listed |
155 |
26.4 |
|
not listed |
155 |
26.4 |
|
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 |
|---|---|
|
A hint: |
|
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. |
|
Rejects connections that can only offer reliable delivery, so a session never silently falls back to HTTP/2 streams without datagrams. |
|
A list of |
|
Application protocols the client prefers; the server’s choice is exposed as |
|
Hints for the initial flow-control credit. |
Lifecycle
-
readyis a promise that resolves when the session is usable and rejects when the connection could not be established. Alwaysawaitit before using streams or datagrams. -
closedresolves with{ closeCode, reason }after a gracefulclose()by either side, and rejects with aWebTransportErroron abrupt termination.wt.close({ closeCode: 4000, reason: 'bye' })ends the session. -
drainingresolves when the session starts draining (the server is going away); finish current work and reconnect. -
reliabilityispending,reliable-onlyorsupports-unreliable.WebTransport.supportsReliableOnlytells whether the browser can do HTTP/2 sessions. -
getStats()returns connection statistics (round-trip time, datagram counters) andexportKeyingMaterial()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:
-
maxDatagramSizeis 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. -
incomingMaxAgeandoutgoingMaxAgedrop 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 anoutgoingMaxAgeof a few ticks. -
incomingMaxBufferedDatagramsandoutgoingMaxBufferedDatagrams(the olderincomingHighWaterMarkandoutgoingHighWaterMark) 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:
-
readyrejects 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. -
serverCertificateHashesrejections: 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:
-
feature-detect (
'WebTransport' in window); -
try WebTransport with a short deadline (race
readyagainst a timer of 3 to 5 seconds); -
fall back to a WebSocket and remember the result for the rest of the session;
-
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.
Source: MDN, WebTransport() constructor
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()
Source: MDN, WebTransport API
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 |
|
A native binding to Google’s quiche. The HTTP/3 part is a separate prebuilt package for Windows, Linux and macOS. Server classes |
Go |
|
Built on quic-go, implements |
Python |
|
A QUIC and HTTP/3 library. Its bundled |
Socket.IO |
|
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.
Source: quic-go/webtransport-go
Source: aioquic, http3_server.py example
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.
Source: Socket.IO, WebTransport guide
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 themoqworking 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 replacesdraft-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 |
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.
serverCertificateHashesis 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
maxDatagramSizeas datagrams; use a stream or split them.