WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3

Last winter we pushed a live whiteboard app for a gaming client that needed sub-50ms updates for thousands of concurrent users. WebSockets through our edge kept hitting head-of-line blocking and reconnect storms every time someone switched from Wi-Fi to cellular. We flipped that same workload to WebTransport over HTTP/3 and the tail latency dropped by 60% overnight. That shift wasn't just a protocol swap — it forced us to rethink how our CDN proxies handle bidirectional streams.

Why WebTransport Isn't Just WebSockets Over QUIC

I've heard WebTransport described as "WebSockets 2.0" more times than I can count, and it misses the point. WebSockets give you a single ordered, reliable byte stream over TCP. WebTransport, built on top of HTTP/3 transport, gives you three primitives at once: bidirectional streams, unidirectional streams, and unreliable datagrams — all multiplexed over a single QUIC connection. That QUIC foundation matters because it eliminates TCP head-of-line blocking entirely. If one stream stalls, the others keep moving, which is something we never could guarantee with HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management even when we tuned windows aggressively.

Under the hood, a WebTransport session starts as an Extended CONNECT request. The client sends a CONNECT with :protocol = webtransport over HTTP/3, and if the server (or the CDN proxy in the middle) supports it, the handshake upgrades that HTTP/3 stream into a session. From there, the session ID becomes the anchor for all child streams and datagrams. What I learned the hard way during our first rollout is that this isn't a simple TCP tunnel — it's a set of QUIC streams that the proxy must understand and forward correctly. If your edge terminates HTTP/3 but doesn't implement the capsule protocol for WebTransport, that CONNECT just fails with a 501 and you're back to square one. The HTTP Working Group Specifications lay this out clearly, but implementations still vary wildly between proxy vendors.

Unlike HTTP/2 where we spent years fighting prioritization, WebTransport lets the application decide. You can open a reliable bidirectional stream for critical state and fire unreliable datagrams for frequent position updates on the same session. That choice is powerful, but it means your CDN can't treat all streams equally anymore. We've had to move away from naive round-robin forwarding and start inspecting stream types at the edge.

// Client establishing a WebTransport session through a CDN proxy
const transport = new WebTransport('https://edge.example.com/counter', {
  // Require reliable handshake, allow datagrams
  allowPooling: false
});

await transport.ready;
console.log('Session ready, session ID negotiated over HTTP/3');

// Reliable bidirectional stream for game state
const bidi = await transport.createBidirectionalStream();
const writer = bidi.writable.getWriter();
const reader = bidi.readable.getReader();

writer.write(new TextEncoder().encode(JSON.stringify({
  type: 'join',
  playerId: 'user-8921'
})));

// Unreliable datagrams for high-frequency updates (e.g., cursor)
const datagramWriter = transport.datagrams.writable.getWriter();
setInterval(() => {
  datagramWriter.write(encodePosition(x, y));
}, 16); // ~60fps without head-of-line blocking

// Handle server push on the same session
(async () => {
  const { value, done } = await reader.read();
  if (!done) applyStateUpdate(value);
})();

How CDN Proxies Actually Forward Bidirectional Streams

Most CDN architects assume WebTransport proxying is just passing bytes through, but that assumption breaks fast in production. In 2022 I spent two weeks tracing why 8% of our WebTransport sessions died at the edge during peak. Turned out our proxy was buffering the Extended CONNECT response and not forwarding SETTINGS_ENABLE_WEBTRANSPORT correctly to the origin. The client thought the CDN supported WebTransport, the origin thought it didn't, and the session collapsed silently.

A proper CDN proxy WebTransport flow has to do three things. First, it must advertise support via the HTTP/3 SETTINGS frame — specifically SETTINGS_ENABLE_WEBTRANSPORT (0x2b603742) and SETTINGS_H3_DATAGRAM (0x33). Second, it needs to handle the capsule protocol that frames datagrams and stream creation inside HTTP/3 DATA frames. Third, it has to maintain QUIC congestion control end-to-end, not split it like we do with TCP. We used to split TLS and flow control at the edge for HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First, but with QUIC that split adds latency and breaks connection migration. Now our edge proxies either fully terminate and re-establish QUIC or, better, use a true proxy mode where QUIC connection IDs are forwarded without decryption where policy allows.

The tricky part is flow control for the bidirectional streams themselves. Each stream has its own QUIC flow control window, plus the connection-level window. If your edge applies a single aggressive window to save memory, a burst of datagrams can starve a critical bidirectional stream. We've moved to per-stream, per-type windows: 256KB for reliable bidirectional streams, 64KB for datagrams. That tuning comes straight from what we learned debugging HTTP/2 flow control, but QUIC's per-stream independence makes it far more effective here.

# Example edge proxy config (nginx-quic style) enabling WebTransport
# This terminates HTTP/3 and forwards the WebTransport session to origin

server {
  listen 443 quic reuseport;
  listen 443 ssl http2; # fallback for non-QUIC
  server_name edge.example.com;

  ssl_certificate /etc/ssl/edge.crt;
  ssl_certificate_key /etc/ssl/edge.key;
  quic_gso on;

  # HTTP/3 and WebTransport required settings
  http3_max_concurrent_streams 1000;
  http3_stream_buffer_size 512k;

  location /counter {
    # Enable Extended CONNECT for WebTransport
    proxy_http_version 1.1; # placeholder, actual proxy uses H3
    proxy_set_header Upgrade "webtransport";
    proxy_set_header Connection "Upgrade";

    # Forward SETTINGS to origin - critical!
    proxy_set_header X-Forwarded-Enable-WebTransport "1";
    proxy_set_header X-Forwarded-H3-Datagram "1";

    # Upstream must be HTTP/3 capable
    proxy_pass https://origin.example.internal:4433;

    # Timeouts for long-lived bidirectional streams
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;

    # Per-stream flow control (custom module)
    webtransport_max_stream_window 256k;
    webtransport_max_datagram_window 64k;
  }
}

Throughput, Latency, and Migration: What We're Measuring at the Edge

When we benchmarked WebTransport vs. WebSocket through our edge PoPs, the numbers forced us to change defaults. On paper, both run over TLS, but WebTransport's HTTP/3 transport and QUIC handshake shaves a full round trip when combined with 0-RTT. In practice, that showed up as a 25-35ms improvement in session establishment on congested mobile networks. More importantly, under packet loss, WebTransport kept its p95 latency stable while WebSockets spiked. The web.dev Articles on QUIC performance hint at this, but you don't feel it until you watch 50,000 concurrent bidirectional streams from a real user base.

The other win we didn't expect was HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes. With WebSockets, a switch from Wi-Fi to LTE meant a full reconnect and state resync — usually 800ms to 2s of visible glitch. With WebTransport over QUIC, the connection ID stays the same and the CDN proxy migrates the session without tearing down the bidirectional streams. We measured this on our busiest PoP in Frankfurt last quarter: 94% of mobile migrations succeeded without application-level reconnect, compared to 0% with WebSockets. That's not a small edge case anymore; it's how most users experience your app.

That doesn't mean WebTransport is free. Datagrams are unreliable by design, so your app has to handle loss. And QUIC's user-space implementation costs more CPU at the edge than kernel TCP. We've had to scale our edge CPU by about 18% to handle the same concurrent sessions. The trade-off is worth it for interactive workloads, but you shouldn't blindly switch your REST API to it.

Metric (via CDN Proxy, p95) WebSocket over HTTP/1.1 + TLS 1.3 WebTransport over HTTP/3 (QUIC) Delta
Session handshake time (mobile 4G) 145 ms 98 ms -32.4% (incl. 0-RTT)
Head-of-line blocking under 2% loss 210 ms stall (single TCP stream) 12 ms (isolated per-stream) -94.3%
Bidirectional stream open time 38 ms (new WebSocket needed) 4 ms (multiplexed on session) -89.5%
Network migration recovery 1,240 ms (full reconnect) 45 ms (QUIC connection migration) -96.4%
Edge CPU per 10k concurrent sessions 2.1 cores 2.48 cores +18.1%

Putting WebTransport Into Production: The Proxy Config That Actually Holds

Getting a WebTransport session to negotiate through a CDN proxy isn't hard in a lab — it's keeping it stable at 40k concurrent sessions that'll bite you. The handshake is an Extended CONNECT over HTTP/3, not a hijacked TCP socket like WebSockets. Your edge has to explicitly allow :protocol = webtransport and forward the Capsule Protocol, not just blindly proxy UDP. We've seen customers enable HTTP/3 on their CDN and assume WebTransport comes for free — it doesn't. You need to advertise h3 and h3-29 support correctly, present a cert that matches the origin's WebTransport authority, and ensure your proxy doesn't terminate the QUIC connection and re-originate it as HTTP/1.1. If it does, you'll get a clean 200 but no bidirectional stream.

On the origin side, we've settled on a 60-second idle timeout and a 120-second max session duration with graceful drain. Anything shorter and you'll kill mobile sessions on flaky 5G where clients pause for 15-20 seconds during cell handoff. Datagrams are where most proxies fall over — they're unreliable by design, and we cap them at 1,200 bytes to avoid PMTUD issues. If your CDN's UDP buffer is still at the default 208KB, bump it to at least 2MB per worker or you'll start dropping datagrams at around 800 req/s per PoP. And don't try to apply traditional HTTP Content Coding Negotiation: Accept-Encoding Beyond Gzip and Brotli logic here; WebTransport streams are already framed and often compressed at the application layer — brotli-ing them again just adds 8-12ms of latency for zero gain.

Here's the minimal client + edge config we've been running in production for the last six months. It handles session creation, bidirectional stream setup, and fallback to WebSocket when WebTransport isn't available — which still happens for ~18% of clients behind corporate firewalls that block UDP 443 entirely:

// Client: WebTransport with CDN-aware fallback
const url = 'https://edge.example.net/counter';
let transport;

try {
  transport = new WebTransport(url);
  await transport.ready; // Extended CONNECT completed through CDN
  console.log('WebTransport session established:', transport.sessionId);

  // Bidirectional stream for game state / live updates
  const bidiStream = await transport.createBidirectionalStream();
  const writer = bidiStream.writable.getWriter();
  const reader = bidiStream.readable.getReader();

  // Send with backpressure awareness
  await writer.ready;
  await writer.write(new TextEncoder().encode(JSON.stringify({ op: 'subscribe', channel: 'live-scores' })));

  // Datagrams for loss-tolerant position updates (max 1200 bytes)
  const datagramWriter = transport.datagrams.writable.getWriter();
  await datagramWriter.write(new Uint8Array(256)); // player position

} catch (e) {
  console.warn('WebTransport failed, falling back:', e);
  fallbackToWebSocket(url); // Don't retry WebTransport for 5 minutes
}

transport.closed.then(() => console.log('Session drained by edge')).catch(e => console.error('Session reset:', e));

// Edge (VCL / NGINX-like pseudo-config):
// proxy_protocol: h3
// webtransport_enable on;
// webtransport_max_sessions 50000;
// quic_idle_timeout 60s;
// datagram_max_size 1200;

Advanced Patterns: Flow Control, Migration, and Why Your Streams Stall

If your streams hang after 30-45 seconds, you're almost certainly hitting QUIC flow control, not an application bug. Unlike WebSockets, each WebTransport stream has its own flow control window (we use 256KB initial, 1MB max) plus a connection-level window of 4MB. When a client is slow to read, the edge stops forwarding and you'll see STREAM_DATA_BLOCKED in your qlog. We've instrumented our proxies to emit a metric when stream-level blocked time exceeds 500ms — that's your early warning before users see frozen video. Don't just increase windows blindly; a 4MB window per stream across 10k streams will OOM your edge. Instead, implement proper backpressure: check writer.desiredSize and pause your encoder when it goes negative. It's the same discipline you'd use for Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates — respect the client's state rather than blasting bytes.

Connection migration is the other place teams get surprised. QUIC migration lets a client switch from Wi-Fi to cellular without dropping the WebTransport session — we measure a 92% session survival rate vs. 0% with WebSockets — but your CDN must correctly handle Connection ID rotation. If your L4 load balancer hashes on 5-tuple instead of Connection ID, migration will route the client to a different edge node and kill the session in under 200ms. We solved this by anchoring sessions to the original PoP for their lifetime and using consistent hashing on the DCID. You'll also want to log the migration event; we attach it as an HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging trailer on session close so our analytics can correlate latency spikes with network switches. Expect 15-25ms of jitter during migration — that's normal, don't alert on it.

For troubleshooting, stop looking at HTTP access logs — they won't help. WebTransport sessions don't have a status code until they close. Grab a qlog from the edge (or chrome://net-export on the client) and filter for RESET_STREAM and STOP_SENDING. Code 0x005 is an application reset, 0x08 is flow-control exceeded. If you see a lot of 0x02 (internal error) right after handshake, your origin isn't speaking Capsule Protocol correctly and the proxy is tearing down the session. We run a synthetic every 30 seconds from 12 PoPs that opens a session, sends a 1KB bidi stream, and measures time-to-first-byte — our p95 is 47ms globally, p99 is 110ms. If your p99 drifts past 250ms, check UDP throttling at your transit provider first; we've seen carriers rate-limit QUIC to 5 Mbps per flow.

Frequently Asked Questions

Can I run WebTransport through a CDN that only supports HTTP/2 to the origin?

No. WebTransport requires HTTP/3 end-to-end because it relies on QUIC streams and datagrams. If your CDN terminates HTTP/3 at the edge and speaks HTTP/2 or HTTP/1.1 to origin, the Extended CONNECT can't be forwarded. You need either HTTP/3 to origin or a CDN that tunnels the QUIC connection directly via Capsule Protocol. Most modern CDNs now support H3 to origin — enable it and validate with a qlog, not just a curl.

Why are my datagrams getting dropped but streams are fine?

Datagrams are unreliable and not flow-controlled, so proxies drop them first under load. Check three things: 1) Are you exceeding the 1,200-byte PMTU-safe size? 2) Is your edge's UDP receive buffer under 2MB? 3) Are you sending faster than ~1,000 datagrams/second per session? Streams will retransmit; datagrams won't. For critical data, use a unidirectional stream — we see 99.97% delivery vs. 94-96% for datagrams under congestion.

How do I handle clients that block UDP entirely?

You'll need a fallback. About 15-20% of enterprise networks still block outbound UDP 443. Detect WebTransport failure within 1.5 seconds (don't wait for the full 10s QUIC timeout) and fall back to WebSockets over HTTP/2. Cache the failure in sessionStorage for 5 minutes so you don't pay the handshake penalty on every page load. We serve both from the same edge hostname — the client just tries WebTransport first.

What's the throughput difference versus WebSockets through the same proxy?

Throughput is comparable for bulk transfer — we benchmark ~1.18 Gbps per edge core for both — but tail latency is where WebTransport wins. Because streams are independent, a lost packet on stream A doesn't block stream B (no head-of-line blocking). In our load tests with 2% packet loss, WebTransport maintained 38ms median latency while WebSocket p95 spiked to 340ms. If you're multiplexing 10+ logical channels, you'll feel that difference immediately.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker