Internet Protocols · QUIC and HTTP/3

Loss Detection Without Retransmitted Packets

How QUIC infers loss from packet-number gaps and time thresholds, why frames rather than packets are retransmitted, and how probe timeouts keep a connection from stalling.

TCP retransmits segments. A lost segment is resent with the same sequence number, and the receiver treats the copy as identical to the original. QUIC cannot do this, because packets are individually encrypted with a nonce derived from the packet number, so a byte-for-byte retransmission would fail authentication. What QUIC retransmits instead is the content: the frames that were in the lost packet are placed into new packets with new numbers, and the receiver reassembles by the offsets inside those frames rather than by the packet number.

Packet numbers are a counter, not a position

Each packet number space counts from zero and increments by one for every packet sent in that space. A connection has three spaces: Initial, Handshake, and Application, the last of which covers both 0-RTT and 1-RTT packets. The separation means that a lost Initial packet does not inflate the numbers used by application data, and that acknowledgement ranges can be kept per space without a mapping between them.

Packet numbers are never reused, and a sender that has skipped a number may not go back to it. This makes the number a reliable indicator of send order: a receiver that has acknowledged packet 20 and then receives packet 18 knows that 18 was reordered, not retransmitted. A gap in the numbers received is a gap in what was sent, so the receiver can report precisely which packets are missing and the sender can infer loss from the acknowledgement rather than from a timer.

The nonce for packet protection is the packet number encoded into the twelve-byte nonce, which is why the number cannot repeat: reusing a number with the same key would reuse a nonce, and that breaks AEAD. The prohibition on reuse is a security requirement, not a bookkeeping convenience.

Two loss declarations: packet threshold and time threshold

A sender declares a packet lost when a later packet has been acknowledged and one of two conditions holds. The packet threshold is a count: if a packet with a number at least three greater than the unacknowledged one has been acknowledged, the earlier one is presumed lost. The number three is the same heuristic TCP uses for duplicate acknowledgements, and it is chosen because reordering of up to that depth is common enough that declaring loss earlier would produce spurious retransmissions.

The time threshold handles the case where the sender has not sent enough later packets to trigger the count. A packet is declared lost if it was sent longer ago than a loss delay computed as 9/8 of the larger of the smoothed and latest round-trip estimates, with a floor equal to the local timer granularity, which is recommended to be one millisecond. The specification expresses the multiplier as a fraction rather than a band around the estimate, and notes that TCP’s RACK algorithm uses a slightly looser threshold, equivalent to 5/4, for the same purpose. The estimate itself is corrected by the acknowledgement delay reported by the receiver, which is why ACK frames carry a delay field rather than only the numbers.

Both conditions are evaluated when an ACK arrives, and neither requires a separate timer per packet. The sender keeps a list of packets in flight, ordered by number, and on each acknowledgement it walks the list to find newly lost entries. The cost is proportional to the number of packets in flight rather than to the connection’s lifetime, which is the property that makes the scheme practical.

/* RFC 9002, Appendix A.10, reduced to the two tests. */
struct sent_pkt { uint64_t num; uint64_t sent_time; int acked; };

static void detect_loss(struct sent_pkt *p, size_t n, uint64_t now,
                        uint64_t largest_acked, uint64_t srtt, uint64_t latest_rtt)
{
    uint64_t loss_delay = 9 * (srtt > latest_rtt ? srtt : latest_rtt) / 8;
    if (loss_delay < 1) loss_delay = 1;          /* kGranularity: 1 ms */
    uint64_t lost_before = now - loss_delay;

    for (size_t i = 0; i < n; i++) {
        if (p[i].acked || p[i].num > largest_acked) continue;
        if (p[i].sent_time <= lost_before ||             /* time threshold */
            largest_acked >= p[i].num + 3)               /* packet threshold */
            declare_lost(&p[i]);
    }
}

Both tests are evaluated against the current time, which means a packet can be lost at the moment the acknowledgement arrives or later when the timer re-evaluates the set. An implementation that only checks on acknowledgement would leave an earlier packet unjudged when the acknowledgement covers none of the packets after it, which is why the same computation is also run from the loss detection timer.

Frames, not packets, are the unit of recovery

When a packet is declared lost, the sender examines which frames it carried and decides what to do with each. STREAM frames are re-queued as new data at the same offset, possibly split differently and possibly coalesced with other retransmissions. CRYPTO frames are re-queued similarly, at the same offset in the handshake message sequence. ACK frames are not retransmitted at all, because an ACK that is lost is superseded by the next one the receiver sends.

This distinction matters for correctness. A frame that is re-queued must not be duplicated in a way that the receiver cannot handle: STREAM frames are idempotent by offset, so a receiver that gets the same bytes twice simply ignores the second copy, but a frame like MAX_STREAMS that carries a value is not idempotent in the same way. The specification handles this by making the MAX_* frames monotonic: a receiver that gets a lower value than it has already seen ignores it, and a higher value is an update. Retransmitting an old MAX_STREAMS frame is therefore harmless, and retransmitting an old RESET_STREAM is harmless because the final size cannot decrease.

The cost of frame-based recovery is that the sender must keep the frame contents until they are acknowledged, which is more state than keeping a copy of the packet. It also means a retransmission can be smaller or larger than the original packet, depending on what else is being sent at the time, which is an advantage: a retransmission can be packed with fresh data and fill a datagram that would otherwise be underfull.

Probe timeout and the two-packet rule

A sender that has nothing to acknowledge and nothing new to send can stall. If the last acknowledgement it received left packets outstanding and no further data is pending, there is no packet whose acknowledgement would trigger loss detection, because loss detection is driven by acknowledgements. The probe timeout exists to break this deadlock: when it fires, the sender emits one or two packets containing a PING or a retransmission of unacknowledged data, sized to elicit an acknowledgement.

The timeout is computed from the smoothed round-trip estimate and its variation, with exponential backoff on repeated firings, and it is capped. The initial value before any measurement is available is derived from a fixed constant rather than from a measured round trip, because the first flight has no estimate yet. The backoff is what prevents a sender from flooding a path that has genuinely failed: each successive probe doubles the wait, up to a limit, and the connection is abandoned after a bounded number of failures.

The “one or two packets” rule is not arbitrary. Sending one packet risks the probe itself being lost, which would require another full timeout. Sending two costs one extra packet and gives the receiver two chances to produce an acknowledgement, and it also provides the second acknowledgement needed to trigger the packet-threshold rule on anything outstanding. The trade is bandwidth against recovery latency, and it is resolved in favour of latency because a stalled connection has already paid the timeout.

Persistent congestion and the collapse of the estimate

If a sender concludes that a long stretch of packets was lost, with no acknowledgement arriving across a period spanning multiple probe timeouts, it declares persistent congestion. The response is not merely to retransmit but to collapse the congestion window to its minimum and to reset the round-trip estimate, on the theory that the path’s characteristics are no longer known.

The declaration requires that the lost packets span a period longer than the persistent congestion duration, which is a multiple of the probe timeout, and that they were sent at a time when the sender was actually transmitting. A sender that was idle and then lost one packet has not experienced congestion; a sender that sent continuously and lost everything across several timeouts has.

The subtlety is that this can fire on a path that was merely idle, or on a path where the application paused. The duration test is meant to exclude those cases, and implementations must be careful about which packets count toward the span. Getting it wrong in the permissive direction means a brief outage resets a well-tuned connection; getting it wrong in the restrictive direction means the connection keeps a window that is far too large for a path that has collapsed.

Acknowledgements are the whole feedback channel

Every estimate the sender has comes from ACK frames. They report the largest packet number received, a set of ranges covering recently received packets, and the delay between receipt and transmission of the acknowledgement. The sender uses the largest number to detect loss, the ranges to know what has arrived, and the delay to correct its round-trip measurement so that it is not inflated by the receiver’s own delay in acknowledging.

A receiver controls how often it acknowledges, within the credit accounting that governs how much data may be outstanding in the first place, as described in two-level flow control, and the specification allows it to acknowledge every second packet, or to delay up to a bounded time, in order to reduce the number of ACK frames it sends. A receiver that acknowledges too eagerly spends bandwidth on acknowledgements; one that delays too long starves the sender’s loss detection. The balance is why the delay field exists: it lets the sender subtract the receiver’s delay from the measured round trip, so a lazy acknowledger does not distort the estimate.

The ACK frame’s range encoding is what keeps this affordable. A connection receiving thousands of packets can report them in a frame whose size grows with the number of gaps rather than with the number of packets, so a clean run of arrivals costs a few bytes. The same varint encoding used everywhere else in the format, described in packet and frame layout on the wire, is what makes each range cost at most a few bytes.

References

  1. RFC 9002: QUIC Loss Detection and Congestion Control — IETF
  2. RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport — IETF
  3. RFC 8985: RACK-TLP: Detection and Recovery of Tail Losses — IETF
  4. RFC 6298: Computing TCP's Retransmission Timer — IETF