Implementing WebTransport for Low-Latency Communication on Website
WebTransport — browser API over HTTP/3 (QUIC), available Chrome 97+, Edge 97+, Firefox 114+. Unlike WebSocket, runs over UDP via QUIC, supports multiple independent streams and datagrams without head-of-line blocking. For tasks requiring <50 ms latency — quality step forward from WebSocket.
Where WebTransport Wins vs WebSocket
WebSocket — single TCP stream. Packet loss blocks entire queue (head-of-line blocking). Poor connection → disproportionate delay growth. QUIC solves with multiplexing: each stream independent, packet loss in one doesn't affect others.
| Characteristic | WebSocket | WebTransport |
|---|---|---|
| Protocol | TCP | QUIC (UDP) |
| Multiplexing | No | Yes (independent streams) |
| Datagrams (unreliable) | No | Yes |
| Head-of-line blocking | Yes | No (different streams) |
| Browser support | All | Chrome 97+, Firefox 114+, Edge 97+ |
| 0-RTT reconnect | No | Yes (QUIC session resumption) |
Server Requirements
WebTransport requires HTTP/3. Options:
-
Go:
quic-go+webtransport-go -
Node.js:
@fails-components/webtransport(experimental) -
Python:
aioquic - Cloudflare Workers: native WebTransport via Workers API
- nginx/Caddy: no native WebTransport proxy yet
Minimal server on Go with webtransport-go:
package main
import (
"context"
"crypto/tls"
"log"
"net/http"
"github.com/quic-go/quic-go/http3"
"github.com/quic-go/webtransport-go"
)
func main() {
s := webtransport.Server{
H3: http3.Server{
Addr: ":4433",
TLSConfig: loadTLSConfig(), // TLS mandatory
},
}
http.HandleFunc("/wt", func(w http.ResponseWriter, r *http.Request) {
session, err := s.Upgrade(w, r)
if err != nil {
log.Printf("upgrade error: %v", err)
return
}
handleSession(session)
})
s.ListenAndServe()
}
func handleSession(session *webtransport.Session) {
ctx := context.Background()
for {
// Accept incoming bidirectional stream
stream, err := session.AcceptStream(ctx)
if err != nil {
return
}
go handleStream(stream)
}
}
Client: Basic Connection
const transport = new WebTransport('https://your-server:4433/wt');
// Wait for readiness
await transport.ready;
console.log('WebTransport connected');
transport.closed.then(() => console.log('Transport closed'));
// Error handling
transport.closed.catch(err => console.error('Transport error:', err));
Bidirectional Streams
Streams — reliable ordered channels, like multiple independent WebSockets in one connection:
// Client opens stream
const stream = await transport.createBidirectionalStream();
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Sending
const encoder = new TextEncoder();
await writer.write(encoder.encode(JSON.stringify({ type: 'subscribe', channel: 'prices' })));
// Reading responses
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const message = JSON.parse(decoder.decode(value));
handleMessage(message);
}
Server can initiate streams to client:
// Client receives incoming streams from server
const streamReader = transport.incomingBidirectionalStreams.getReader();
while (true) {
const { value: stream, done } = await streamReader.read();
if (done) break;
processServerStream(stream);
}
Datagrams (Unreliable)
Unreliable, unordered UDP-like messages. For player positions, metrics, cursors — latency matters over guarantee:
// Send datagram
const datagramWriter = transport.datagrams.writable.getWriter();
const encoder = new TextEncoder();
function sendPosition(x, y) {
const data = encoder.encode(JSON.stringify({ x, y, ts: Date.now() }));
// No ack wait, fire-and-forget
datagramWriter.write(data).catch(() => {}); // packet loss is normal
}
// Receive datagrams
const datagramReader = transport.datagrams.readable.getReader();
const decoder = new TextDecoder();
(async () => {
while (true) {
const { value, done } = await datagramReader.read();
if (done) break;
const msg = JSON.parse(decoder.decode(value));
updateRemotePosition(msg);
}
})();
Unidirectional Streams
For streaming data send (events log, binary data):
// Client → Server: unidirectional stream
const sendStream = await transport.createUnidirectionalStream();
const writer = sendStream.getWriter();
await writer.write(encodeChunk(data));
await writer.close();
// Server → Client: incoming unidirectional streams
const incomingReader = transport.incomingUnidirectionalStreams.getReader();
while (true) {
const { value: stream, done } = await incomingReader.read();
if (done) break;
const reader = stream.getReader();
// read data from stream
}
Real Case: Trading Terminal
Stock quotes need minimal latency. Architecture with WebTransport:
Exchange feed (UDP) -> Go server -> WebTransport -> Browser
- Datagrams for price ticks (fire-and-forget, 1–2% loss acceptable)
- Reliable stream for orders and confirmations
- Separate stream for instrument subscriptions
TLS Certificate Problem in Development
WebTransport requires valid TLS. Dev-environment options:
1. Chrome flag for self-signed:
chrome://flags/#allow-insecure-localhost
2. Certificate pinning via serverCertificateHashes:
const transport = new WebTransport('https://localhost:4433/wt', {
serverCertificateHashes: [{
algorithm: 'sha-256',
value: hexToArrayBuffer('YOUR_CERT_SHA256_HASH'),
}],
});
Certificate max 14 days when using this method — generated on dev-server start.
Fallback Strategy
WebTransport unsupported Safari (as of early 2026). Graceful fallback needed:
async function createTransport(url) {
if ('WebTransport' in window) {
try {
const wt = new WebTransport(url.replace('wss://', 'https://'));
await wt.ready;
return new WebTransportAdapter(wt);
} catch (e) {
console.warn('WebTransport failed, falling back to WebSocket');
}
}
return new WebSocketAdapter(url);
}
Adapter hides difference behind unified send(data) / on('message', cb) interface.
Timeline
- Prototype with datagrams and one stream — 2–3 days
- Production server on Go with TLS + complex stream handling — 1 week
- Full client with WebSocket fallback, latency monitoring, reconnect — 2–3 weeks
- Integration with existing real-time app (replace WebSocket) — 3–5 days with adapter layer







