WebTransport browser support and fallbacks #
Your WebTransport prototype works beautifully in Chrome on your office network. Then real users arrive: some browsers do not expose WebTransport at all, some corporate and hotel networks block UDP on port 443 so the QUIC handshake silently times out, and some mobile carriers throttle UDP so heavily that WebTransport performs worse than the WebSocket it replaced. A WebTransport feature therefore always ships as WebTransport with a WebSocket fallback, and the quality of the fallback logic — how fast it detects failure, how it avoids penalizing users on good networks, and how the application protocol stays identical across both — decides whether adopting WebTransport is a win or a support burden.
Root cause #
WebTransport has three independent prerequisites, and any one can be missing. The browser must implement the API; support landed first in Chromium-based browsers and Firefox, with others following, and embedded web views vary. The network path must carry UDP to your server — QUIC runs over UDP, and some firewalls allow only TCP to 443, while others allow UDP but rate-limit it as a DoS defence. And your server and load balancer must speak HTTP/3 with WebTransport enabled, over a valid TLS certificate (or explicitly pinned certificate hashes in development).
The dangerous failure is the network one, because it is silent. A blocked UDP path does not return an error; the QUIC handshake packets simply go unanswered, and new WebTransport(url).ready waits until the browser’s own handshake timeout — which can be many seconds. A client that tries WebTransport first and waits for that timeout before falling back makes every user on a restrictive network stare at a spinner.
Resolution #
Feature-detect first. If WebTransport exists, start it, and start a WebSocket connection shortly afterwards (a small head start for WebTransport keeps it preferred on good networks). Use whichever reaches a working state first for now, give WebTransport a short deadline, and upgrade to it if it comes up after the WebSocket did. Remember the outcome per network so returning users skip doomed attempts. Keep one application protocol, so the rest of the app never knows which transport carried a message.
interface RtTransport {
kind: 'webtransport' | 'websocket';
send(msg: object): void;
onMessage(cb: (msg: any) => void): void;
close(): void;
}
const WT_HEAD_START_MS = 150; // let WebTransport win when both work
const WT_DEADLINE_MS = 3_000; // stop waiting for QUIC after this
const REMEMBER_FAILURE_MS = 24 * 60 * 60 * 1000;
const FAIL_KEY = 'wt-failed-until';
async function openWebTransport(url: string): Promise<RtTransport> {
const wt = new WebTransport(url);
await wt.ready;
const stream = await wt.createBidirectionalStream(); // one ordered lane = WS semantics
const writer = stream.writable.getWriter();
const enc = new TextEncoder();
let cb: (m: any) => void = () => {};
(async () => { // newline-delimited JSON reader
const reader = stream.readable.pipeThrough(new TextDecoderStream()).getReader();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
let i;
while ((i = buf.indexOf('\n')) >= 0) { cb(JSON.parse(buf.slice(0, i))); buf = buf.slice(i + 1); }
}
})();
return { kind: 'webtransport', send: (m) => void writer.write(enc.encode(JSON.stringify(m) + '\n')),
onMessage: (f) => { cb = f; }, close: () => wt.close() };
}
function openWebSocket(url: string): Promise<RtTransport> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
let cb: (m: any) => void = () => {};
ws.onmessage = (e) => cb(JSON.parse(e.data));
ws.onopen = () => resolve({ kind: 'websocket', send: (m) => ws.send(JSON.stringify(m)),
onMessage: (f) => { cb = f; }, close: () => ws.close(1000) });
ws.onerror = () => reject(new Error('websocket failed'));
});
}
export async function connect(wtUrl: string, wsUrl: string): Promise<RtTransport> {
const wtAllowed = 'WebTransport' in window && Date.now() > Number(localStorage.getItem(FAIL_KEY) ?? 0);
if (!wtAllowed) return openWebSocket(wsUrl);
const wtAttempt = Promise.race([
openWebTransport(wtUrl),
new Promise<never>((_, rej) => setTimeout(() => rej(new Error('wt deadline')), WT_DEADLINE_MS)),
]);
wtAttempt.catch(() => localStorage.setItem(FAIL_KEY, String(Date.now() + REMEMBER_FAILURE_MS)));
const wsAttempt = new Promise<RtTransport>((r) => setTimeout(r, WT_HEAD_START_MS)).then(() => openWebSocket(wsUrl));
// First transport to succeed wins; if both fail, the error surfaces to the caller.
return Promise.any([wtAttempt, wsAttempt]);
}
In production, close whichever transport lost the race, and consider upgrading a session from WebSocket to WebTransport when the latter comes up late: resume on the new transport from the last sequence number, then close the old one, the same stitching used in resuming WebSocket sessions after reconnect. Storing the failure keyed only in localStorage is a simplification — a laptop moves between networks — so a shorter memory or a key that includes a coarse network hint (such as the connection type) avoids penalizing the user on their home network for their office’s firewall.
The server must accept both transports with the same message handling. The cleanest arrangement is a transport adapter per connection feeding one router, as in building a WebSocket message router in TypeScript, with sessions and sequence numbers independent of transport.
Edge cases #
Certificates. WebTransport requires a valid TLS certificate for the host; self-signed certificates work only with serverCertificateHashes, which is limited to short-lived certificates and intended for development and peer-to-peer scenarios. Production endpoints need a normal certificate, and HTTP/3 must be advertised or reachable on the port you connect to.
UDP throttling rather than blocking. Some networks let QUIC through but rate-limit UDP, so WebTransport connects and then performs badly. Monitor per-transport latency and loss after connection, and fall back mid-session if WebTransport underperforms the WebSocket baseline for that user.
Load balancers. QUIC connections are identified by connection IDs, not the UDP 4-tuple. Balancers that hash on the 4-tuple break connection migration and can misroute packets after NAT rebinding. Use QUIC-aware balancing or route by connection ID, as covered in QUIC connection migration for real-time apps.
Verification #
Test the three failure cases deliberately. Disable the API with a browser that lacks it (or delete window.WebTransport in a test) and confirm an immediate WebSocket connection. Block UDP to your test server with a firewall rule and confirm the WebSocket is in use within about half a second and the failure is remembered. Then allow everything and confirm WebTransport wins the race:
# Simulate a UDP-blocking network for the test server (Linux client side).
sudo iptables -A OUTPUT -p udp --dport 443 -d 203.0.113.10 -j DROP
# ... load the app; expect kind === 'websocket' within ~0.5 s ...
sudo iptables -D OUTPUT -p udp --dport 443 -d 203.0.113.10 -j DROP
In production, report the transport chosen and the time to first message for every session. The WebTransport share tells you how much of your audience benefits; time to first message by transport tells you whether the race is costing anyone.
Operational checklist #
FAQ #
Which browsers support WebTransport? #
Chromium-based browsers (Chrome, Edge, Opera) and Firefox support it, and support in other engines has been arriving more recently. Because support and embedded web views vary, always feature-detect and fall back rather than relying on a compatibility table.
Why does WebTransport hang instead of failing on some networks? #
The network drops UDP packets silently, so the QUIC handshake waits for replies that never come. There is no immediate error to react to; only a deadline and a racing fallback avoid the wait.
Should I try WebTransport first or WebSocket first? #
Race them, with a small head start for WebTransport. Trying WebTransport first sequentially penalizes users on UDP-blocking networks by the full handshake timeout; trying WebSocket first means WebTransport never gets used.
Can I use a WebTransport polyfill? #
There is no true polyfill, because browsers without the API cannot speak QUIC from JavaScript. Libraries that offer a WebTransport-like API over WebSockets exist, but they only reproduce the interface, not datagrams or independent streams.
Related #
- WebTransport vs WebSocket — whether the effort is worth it.
- Falling Back from WebSockets to SSE — the next fallback down.
- WebSockets Behind Corporate Proxies — the networks that also block UDP.
- Designing a WebSocket Message Envelope — one protocol for both transports.
Back to WebTransport & HTTP/3.