QUIC doesn’t actually "lose" packets in the way UDP does; it just detects them as lost and handles retransmission at the application layer, which is a fundamentally different approach to network reliability.

Let’s see what happens when QUIC hits some bad network conditions. Imagine we’re testing a web server running on 192.168.1.100 and a client on 192.168.1.200. We’ll use iperf3 with QUIC enabled to simulate traffic and then introduce packet loss.

First, set up a baseline with iperf3 on the server:

iperf3 -s -p 4433 --quic

On the client, initiate a test:

iperf3 -c 192.168.1.100 -p 4433 --quic --time 30

Now, let’s introduce packet loss. We can use tc (traffic control) on the server to simulate a lossy link. Let’s create a condition where 5% of packets are dropped.

# Create a new qdisc for the interface facing the client (e.g., eth0)
sudo tc qdisc add dev eth0 root netem loss 5%

# Run the client test again
iperf3 -c 192.168.1.100 -p 4433 --quic --time 30

Observe the output. You’ll likely see a significant drop in throughput compared to the no-loss baseline. The client might also report increased RTTs as QUIC’s loss detection and retransmission mechanisms kick in.

The core problem QUIC solves is the "head-of-line blocking" inherent in TCP. In TCP, if a packet is lost, all subsequent packets in the same stream must wait for that lost packet to be retransmitted and arrive before they can be processed, even if they’ve already arrived at the receiver. QUIC, by multiplexing streams over UDP, allows independent streams to progress even if another stream experiences packet loss.

Here’s a simplified view of how QUIC handles this:

  1. UDP as a Foundation: QUIC packets are sent over UDP. This means UDP’s inherent unreliability is the starting point.
  2. Connection Establishment: QUIC uses a 0-RTT or 1-RTT handshake (faster than TCP’s 3-way handshake) that also includes TLS 1.3 encryption. This handshake negotiates cryptographic keys and connection parameters.
  3. Stream Multiplexing: QUIC allows multiple independent streams of data within a single connection. Each stream has its own sequence number.
  4. Packet Numbering: QUIC introduces its own packet numbering scheme, independent of stream sequence numbers. This is crucial for loss detection.
  5. Loss Detection: When a QUIC sender sends packets, it assigns them a unique packet number. The receiver acknowledges these packet numbers. If the sender doesn’t receive an acknowledgment for a packet within a certain time (based on RTT estimates), it flags that packet as lost.
  6. Retransmission: Upon detecting a lost packet, QUIC retransmits only the lost packet. Because streams are independent, other streams that haven’t lost packets can continue to be processed.
  7. Congestion Control: QUIC implements its own congestion control algorithms (often Cubic or BBR, similar to TCP but operating at the application layer) to manage network bandwidth and avoid overwhelming the network. This is also a key tuning parameter.

Let’s look at tuning. If you’re seeing poor performance under loss, the congestion control algorithm is a prime suspect. Many QUIC implementations allow you to specify the algorithm. For instance, in nghttp3 (a common QUIC implementation), you might configure this at compile time or runtime.

Consider a scenario where you want to use BBR for better performance on lossy links. If your QUIC server (e.g., using lsquic or mvfst) supports it, you’d typically enable it via a configuration flag or command-line argument. For lsquic, it might be an option like --congestion-control bbr.

The quic-go library allows specifying congestion control algorithms via quic.Config:

import "github.com/quic-go/quic-go"

config := &quic.Config{
    // ... other configs
    CongestionControl: "bbr", // or "cubic", "reno"
}

The reason this works is that different congestion control algorithms have varying sensitivities to packet loss. BBR, for instance, tries to estimate the bottleneck bandwidth and RTT and aims to operate at those limits, which can be more resilient to moderate packet loss than older algorithms like Reno.

Another critical parameter is the initial congestion window (IW). Just like TCP, QUIC has an initial window that determines how much data can be sent before waiting for an acknowledgment. Increasing this can help ramp up sending rate faster, but can also exacerbate congestion if set too high.

For example, if your QUIC implementation allows it, you might increase the initial congestion window from the default 10 to 20 or 30. This is often a compile-time or build-time option depending on the library. A larger IW means the sender can send more data before receiving ACKs, allowing it to probe for bandwidth more effectively at the start of a connection, especially over high-latency links.

Finally, idle timeout is important. QUIC connections can be kept alive with keep-alive packets. If this timeout is too short, the connection might be prematurely closed on a network that experiences intermittent packet loss, forcing a new connection setup. Setting a longer idle timeout (e.g., 60 seconds instead of 30) can help maintain connections across brief network hiccups.

The most counterintuitive aspect of QUIC’s loss recovery is that while it avoids TCP’s stream-level head-of-line blocking, it still suffers from packet-level head-of-line blocking within a single packet. If a QUIC packet contains multiple frames from different streams, and that packet is lost, all the data within that packet, regardless of which stream it belongs to, is blocked until retransmission. This is a subtle but critical distinction from TCP’s HOL blocking.

The next challenge you’ll encounter is tuning QUIC’s cryptographic handshake performance, especially in high-latency environments.

Want structured learning?

Take the full Quic course →