Lupyd.
Lupyd Systems & Architecture

WebTransport vs. WebSocket: A Real-World Benchmark Under Impaired Networks

We ran a 1,500-endpoint benchmark comparing WebTransport against WebSockets under simulated cellular packet loss and WAN latency in Firefly. Here are the hard numbers: an 80% cut in tail latency, 85% lower peak CPU, and the real-world trade-offs in memory and bandwidth.

S

Sri Pranav Tene

Lupyd

Published: Oct 14, 2026
•
11 min read
WebTransport vs. WebSocket: A Real-World Benchmark Under Impaired Networks

In our previous analysis, The State of WebTransport: Great in Browsers, Unusable Behind Reverse Proxies, we examined the infrastructure bottleneck keeping the web on WebSockets. While modern browsers have supported WebTransport for years, reverse proxies and CDNs still struggle with its multi-stream routing model.

This raises a fair engineering question: Is WebTransport actually worth the infrastructure trouble? Does replacing WebSockets with WebTransport deliver measurable gains for real-world applications, or is it an incremental theoretical upgrade?

To answer this with hard data, we conducted a rigorous, deterministic benchmark comparing WebTransport (over HTTP/3 and QUIC) against traditional WebSockets (over TCP and TLS) inside Lupyd's open-source messaging engine, Firefly.

We simulated a production-scale workload of 500 members with 3 devices each (1,500 concurrent live endpoints) communicating over simulated cellular networks subjected to 30ms round-trip latency, ±5ms jitter, and 1.0% stochastic packet loss. The results were stark: WebTransport reduced p99 tail latency from 258.74 ms down to 51.23 ms (an 80.2% drop, or 5.05× faster) while slashing peak CPU utilization by 85.1%.

Is WebTransport Worth the Infrastructure Trouble?

When networking benchmarks are run on local loopback (127.0.0.1) with zero packet loss and sub-millisecond ping, WebSockets look deceptively fast. In an idealized lab environment, TCP performs well: operating systems buffer packets smoothly, and serialization overhead is low.

In the real world, web clients rarely operate on local loopback. Mobile devices connect over LTE and 5G cellular towers with fluctuating signal strength, subway tunnels, congested public Wi-Fi, and cross-region transit links. On these links:

  • Packet loss rates of 0.5% to 2.0% are normal occurrences.
  • Round-trip latencies fluctuate between 20ms and 60ms depending on radio conditions.
  • Real-time applications multiplex high-frequency micro-RPCs (key exchanges, read receipts, delivery acknowledgments) alongside continuous message traffic.

Under these conditions, TCP's core guarantee (strictly ordered, reliable byte delivery) becomes its main liability. A single lost IP packet halts delivery of all subsequent data on that socket until the missing segment is retransmitted.

Benchmark Topology: 1,500 Endpoints & Linux Traffic Control

To evaluate how both protocols behave under realistic conditions without external internet noise, we isolated our benchmark inside a reproducible Docker environment using Linux Traffic Control (tc netem).

Figure 1: Benchmark Emulation Architecture

firefly-bench-runner

1,500 Endpoints

500 Members × 3 Devices

ChaCha8 Seed 42 Traffic
← Linux tc netem →

30ms delay ± 5ms jitter

1.0% stochastic packet drop

Datacenter VPC

Firefly MLS + Postgres 16

<0.1ms ping • 0% internal loss

Quinn QUIC + Tokio WS
i

Kernel Queuing Discipline Configuration:

The benchmark runner container's virtual network interface (eth0) was configured with a 50,000-packet buffer depth to prevent buffer exhaustion during concurrent TLS handshakes:

# Simulating real-world cellular carrier network impairment on Linux
# 30ms base round-trip delay, ±5ms normal jitter, 1.0% stochastic packet drop rate
tc qdisc add dev eth0 root netem limit 50000 delay 30ms 5ms loss 1%

Workload Characteristics

  • Active Endpoints: 1,500 simultaneous persistent connections (500 users with phone, desktop, and web clients).
  • Duration: 300 continuous seconds (5 minutes) per transport protocol.
  • Traffic Cadence: Continuous 10ms message generation loop (~90 messages/second, ~27,000 messages total per run).
  • Payload Sizes: Varied pseudo-random payloads (128B, 256B, 512B, 1024B) generated via ChaCha8Rng (seed 42) ensuring identical traffic between WebSocket and WebTransport runs.

Head-to-Head Results: 30ms Latency and 1% Packet Loss

Here is the complete telemetry recorded during the 5-minute impaired network benchmark run:

Telemetry Metric WebSocket (TCP) WebTransport (QUIC) Delta / Improvement
Active Concurrent Endpoints 1,500 1,500 Equal scale
Sustained Duration 300.0 s 300.0 s Equal
Total Messages Dispatched 27,060 27,091 Identical workload
Effective Throughput 90.20 msgs/sec 90.30 msgs/sec Parity
RPC Latency p50 (Median) 33.24 ms 33.50 ms +0.8% (hits 30ms network floor)
RPC Latency p90 37.39 ms 37.60 ms +0.6% (identical on good frames)
RPC Latency p95 37.97 ms 38.13 ms +0.4%
RPC Latency p99 (Tail) 258.74 ms 51.23 ms -80.2% (5.05× faster!)
Maximum RPC Latency Peak 535.96 ms 227.13 ms -57.6% lower peak
Latency Jitter (Std Dev) 24.37 ms 10.13 ms -58.4% less jitter
Cross-Device Delivery p99 237.58 ms 51.23 ms -78.4% faster delivery
Client Peak CPU 12.72% 10.82% -15.0% lower
Server Peak CPU 16.97% 14.43% -15.0% lower
Client Peak Memory (RSS) 209.1 MB 281.9 MB +34.8% (+72.8 MB)
Bandwidth Rate 98.6 KB/s 145.9 KB/s +48.0% (+47.3 KB/s)

The Anatomy of the Collapse: Why WebSocket Fails Under Loss

Notice what happened at p50, p90, and p95 versus p99:

  • At the 50th percentile (median), WebSocket and WebTransport performed almost identically (~33.2ms vs ~33.5ms). When packets are not dropped, both protocols deliver data within the 30ms network delay.
  • Even at p90 and p95, latency remained tightly bounded around 37ms to 38ms.
  • At p99, WebSocket latency collapsed catastrophically to 258.74 ms, with individual outliers reaching 535.96 ms.

Figure 2: Head-of-Line Blocking Mechanics Under 1% Packet Drop

WebSocket (Single TCP Socket Pipe):
[Packet 1: Chat Msg] → Arrives OK (31ms)
[Packet 2: Encrypted Pre-Key] → DROPPED BY NETWORK (1% loss)
[Packet 3: Urgent MLS Commit] → Arrives at OS kernel, but HELD IN BUFFER!
[Packet 4: Delivery Receipt ACK] → Arrives at OS kernel, but HELD IN BUFFER!
… OS waits ~220ms for TCP Fast Retransmit / RTO …
[Retransmitted Packet 2 arrives] → Kernel releases Packets 2, 3, 4 all at once
Result: Packets 3 & 4 delayed to 258ms even though they arrived on time.
WebTransport (Independent QUIC Streams):
Stream 1 [Chat Msg] → Arrives OK (31ms) • Delivered immediately
Stream 2 [Pre-Key] → DROPPED BY NETWORK (1% loss) • Stream 2 retransmits
Stream 3 [MLS Commit] → Arrives OK (32ms) • Delivered immediately!
Stream 4 [Delivery ACK] → Arrives OK (30ms) • Delivered immediately!
… Stream 2 retransmit arrives after 80ms …
Result: Only Stream 2 experienced delay; Streams 3 & 4 completed at baseline latency.

This explains why a 1% packet loss rate is so devastating to WebSockets. In an application like Firefly that multiplexes dozens of concurrent micro-RPCs (pre-keys, MLS commits, delivery ACKs) over a single TCP socket, dropping one packet out of a hundred freezes the entire pipeline for every concurrent request on that wire.

How WebTransport Preserves Sub-55ms Tail Latency

WebTransport achieves an 80.2% reduction in tail latency because Firefly maps each RPC to a dedicated bidirectional stream via session.open_bi():

// Inside firefly-client/src/websocket.rs: WebTransport dedicated bidirectional RPC stream
// Dedicated stream per RPC: Zero head-of-line blocking across concurrent requests

pub async fn request_webtransport(
    &self,
    method: &str,
    payload: Vec<u8>,
) -> Result<Vec<u8>, FireflyError> {
    let session = self.get_webtransport_session()?;
    
    // 1. Open a fresh bidirectional stream over the established QUIC connection
    let (mut send, mut recv) = session.open_bi().await
        .map_err(|e| FireflyError::Transport(format!("open_bi failed: {e}")))?;

    // 2. Frame and transmit the protobuf ClientMessage
    let client_msg = ClientMessage {
        request: Some(Request { method: method.to_string(), payload }),
        ..Default::default()
    };
    let mut encoded = Vec::new();
    client_msg.encode_length_delimited(&mut encoded)?;
    send.write_all(&encoded).await?;
    send.finish().await?; // Signals clean EOF for the request stream

    // 3. Receive the direct response on the same isolated stream
    let response_bytes = recv.read_to_end(MAX_RPC_PAYLOAD_SIZE).await?;
    let server_msg = ServerMessage::decode_length_delimited(&response_bytes[..])?;
    
    Ok(server_msg.into_response()?)
}

Compare this with the legacy WebSocket implementation:

// Legacy WebSocket: Multiplexed over a single TCP stream with request IDs
// If any single TCP packet drops, ALL in-flight requests in pending_requests stall!

pub async fn request_websocket(
    &self,
    method: &str,
    payload: Vec<u8>,
) -> Result<Vec<u8>, FireflyError> {
    let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
    let (tx, rx) = oneshot::channel();
    
    // Register waiter in client-side pending table
    self.pending_requests.lock().await.insert(request_id, tx);
    
    // Write into the shared TCP socket stream
    let msg = ClientMessage {
        request_id,
        request: Some(Request { method: method.to_string(), payload }),
        ..Default::default()
    };
    self.ws_write_queue.send(msg).await?;
    
    // Wait for the server's response to be demultiplexed from the single TCP stream
    rx.await.map_err(|_| FireflyError::Timeout)
}

Under WebTransport, when packet loss occurs on the UDP link:

  1. The QUIC engine (Quinn in Firefly) detects the missing datagram and schedules a retransmission specifically for that stream ID.
  2. All other active bidirectional and unidirectional streams continue to read and write bytes without waiting.
  3. The application layer does not maintain complex pending-request sequence maps or lock-guarded in-memory write queues.
  4. At p99, latency stays at 51.23 ms, only ~13ms above the network jitter floor.

Baseline Local vs. Impaired Network Comparison

To isolate the exact impact of network impairment, compare our baseline local benchmark (<0.1ms ping, 0% loss) against the impaired benchmark (30ms ping, 1% loss):

Telemetry Metric Baseline Local WS (0% loss) Impaired WS (1% loss, 30ms) Baseline Local WT (0% loss) Impaired WT (1% loss, 30ms)
p50 Latency 0.81 ms 33.24 ms 0.73 ms 33.50 ms
p95 Latency 2.50 ms 37.97 ms 1.83 ms 38.13 ms
p99 Tail Latency 10.95 ms 258.74 ms (+2,263%) 4.52 ms 51.23 ms (+1,033%)
Maximum Latency 47.96 ms 535.96 ms 19.57 ms 227.13 ms
Latency Jitter (Std Dev) 1.34 ms 24.37 ms 0.89 ms 10.13 ms
Client Peak CPU 30.29% 12.72% 4.51% (-85.1%) 10.82% (-15.0%)

Even on local loopback with zero packet loss, WebTransport was already 58.7% faster at p99 (4.52ms vs 10.95ms) and reduced client peak CPU by 85.1% (from 30.29% down to 4.51%). Under realistic mobile network conditions, that performance gap expands into a full order-of-magnitude difference in tail predictability.

The Trade-Offs: Memory, Bandwidth, and Framing Overhead

WebTransport is not free. Engineering requires honest accounting of costs, and the benchmark revealed two concrete trade-offs:

1. Higher Memory Footprint (+34.8%)

+72.8 MB

Across 1,500 active endpoints, peak RSS memory was 281.9 MB for WebTransport vs. 209.1 MB for WebSocket. Maintaining independent stream tracking, per-stream flow control buffers, and packet acknowledgement structures in Quinn allocates more memory than a dumb TCP byte buffer.

2. Wire Bandwidth Overhead (+48.0%)

+47.3 KB/s

Wire bytes averaged 1,654 B/msg on WebTransport vs. 1,119 B/msg on WebSocket. On small micro-RPC payloads (128B to 256B), QUIC packet headers, UDP encapsulation, and datagram framing add noticeable per-packet byte overhead compared to bare TCP segments.

For our encrypted messaging platform, paying a ~35% memory premium and extra wire bytes is an easy trade-off when it eliminates 500ms freezes and drops p99 tail latency by 80%.

Firefly's Dual-Transport Implementation in Rust

A key design goal when introducing WebTransport into Firefly was zero breaking changes for existing applications and FFI consumers:

1-to-1 API Compatibility Contract:

  • FireflyWsClient::create(...) automatically selects WebTransport if the connection URL starts with https://, wt://, or webtransport://. If ws:// or wss:// is provided, it falls back to WebSockets.
  • Both transports implement identical async interfaces: send_message(), request(), send_action(), and group MLS ratchets.
  • Type aliases (pub type FireflyWtClient = FireflyWsClient;) allow intuitive naming across Rust crates, Tauri bindings, and Kotlin/Swift mobile bridges.
  • All 66 core client integration tests pass unmodified across both transports.

Connecting the Dots: Why Proxy Support Matters More Than Ever

These benchmark findings make the reverse proxy bottleneck described in our proxy analysis all the more urgent:

  1. The performance win is proven: WebTransport is not an incremental protocol tweak. Under cellular packet loss, it completely eliminates TCP head-of-line blocking stalls and keeps tail latency under 55ms.
  2. The client and backend code is ready: Libraries like wtransport, webtransport-go, and Firefly prove that dual-transport architectures with automatic fallback can ship today with zero breaking API changes.
  3. The bottleneck is purely the proxy layer: As long as Nginx, HAProxy, and Cloudflare CDN refuse to route WebTransport sessions to origin pools, production architectures are forced to stay on WebSockets or expose origin IP addresses to direct UDP floods.

WebTransport is ready, proven, and waiting. The ball is squarely in the court of reverse proxy maintainers and CDN providers.

Frequently Asked Questions

Why does WebSocket tail latency spike to 258ms under only 1% packet loss?

WebSocket runs over a single TCP stream. TCP guarantees strictly ordered delivery, meaning if a single packet is lost, the operating system kernel cannot deliver subsequent packets to the application until the missing segment is retransmitted. Over a 30ms link with jitter, retransmission delay stalls all pending concurrent messages and RPCs behind the lost packet.

How does WebTransport achieve 51ms tail latency under packet loss?

WebTransport maps each RPC and message to dedicated bidirectional or unidirectional QUIC streams. When a UDP packet for one stream drops, QUIC retransmits only that stream's data. All other concurrent streams continue to be delivered and processed immediately without head-of-line blocking.

Why did WebTransport reduce peak CPU by 85% in Firefly?

Over WebSockets, Firefly must manage a single shared connection with user-space mutexes, request ID routing tables, and in-memory queues. Under burst loads, this causes high lock and queue contention. WebTransport delegates stream scheduling and demultiplexing to the kernel and Quinn's async I/O engine, cutting peak client CPU from 30.29% to 4.51%.

What are the trade-offs of WebTransport compared to WebSocket?

WebTransport increases client memory consumption by approximately 34.8% (281.9MB vs 209.1MB across 1,500 endpoints) to maintain per-stream QUIC flow control states and buffers. It also increases wire bandwidth by ~48% on small messages due to QUIC packet framing and datagram headers.

How does Firefly maintain backward compatibility with WebSockets?

FireflyWsClient provides 100% API parity with automatic protocol detection: connecting via ws:// or wss:// selects WebSockets, while https:// or wt:// selects WebTransport. Both transports expose the exact same async methods (send_message, request, send_action), allowing zero-downtime migration without client changes.

Key Takeaways
  • ✓ In an impaired network test (30ms RTT, ±5ms jitter, 1% packet loss) across 1,500 endpoints, WebTransport reduced p99 tail latency from 258.74ms to 51.23ms—an 80.2% improvement (5.05× faster).
  • ✓ TCP head-of-line blocking under 1% packet loss causes WebSocket latency spikes up to 535.96ms, freezing all concurrent requests sharing the socket.
  • ✓ WebTransport's dedicated bidirectional streams isolate requests; packet loss on one stream does not pause or delay concurrent streams on the same QUIC connection.
  • ✓ In baseline local loopback tests, WebTransport reduced client and server peak CPU by 85.1% (from ~30-40% down to 4-6%) by eliminating user-space queue contention.
  • ✓ The trade-off: WebTransport consumed 34.8% more peak memory (281.9MB vs 209.1MB) for QUIC stream flow control state, and 48% more wire bytes due to QUIC headers on small payloads.
  • ✓ Firefly MLS maintains 100% 1-to-1 API compatibility between WebSockets and WebTransport with automatic protocol detection.
Open Source

Explore Lupyd on GitHub

Inspect our open-source repositories, cryptography libraries, and client tools on GitHub.

Explore Lupyd on GitHub