Internet Protocols · QUIC and HTTP/3
Congestion Control and Pacing in a Transport That Owns the Socket
How a QUIC sender turns acknowledgements into a window, why the window must be spread into a packet schedule, and what changes when the controller is not in the kernel.
A TCP implementation receives its congestion signal from the kernel, which owns the socket, the send queue, and the timers. A QUIC implementation owns all three itself. That changes less about the control law than it might seem — the algorithms are the same family — but it changes a great deal about when packets actually leave, because there is no kernel scheduler to smooth out the bursts.
The window is measured in bytes, and it gates every send
The congestion window is a byte count, and a sender may not have more bytes outstanding than the window allows. Outstanding means sent and not yet acknowledged or declared lost. Each acknowledgement of new data increases the window, each loss decreases it, and the sum of the two effects over a round trip is what determines whether the connection accelerates or backs off.
The specification describes a NewReno-style response as the baseline that every implementation must approximate: the window grows by roughly one maximum-size segment per acknowledged segment in slow start, and by roughly one segment per round trip once a loss has established a threshold, with the threshold set to half the window at the point of loss. It also permits other controllers, and the two in wide use are CUBIC, specified for TCP in RFC 9438, and BBR, which models the path’s bandwidth and round-trip time rather than reacting to loss.
The choice matters because loss-based and model-based controllers disagree about what an empty queue means. A loss-based controller keeps increasing until something is dropped, which fills buffers and adds delay. A model-based controller estimates the bandwidth from the rate at which acknowledgements arrive and tries to keep the window at the bandwidth-delay product plus a small margin, which keeps queues short but can be unfair to loss-based flows sharing the same bottleneck. QUIC does not resolve this; it inherits it.
Why the window alone is not enough
A sender that waits for the window to open and then dumps the entire window into the network sends a burst. The burst arrives at a bottleneck queue all at once, overflows it, and produces losses that have nothing to do with the connection’s actual rate. The window may be correct in the aggregate and still produce a packet train that no queue can absorb.
Pacing fixes this by scheduling packets at a rate rather than releasing them in a clump. The window it paces against is the product of the two flow-control limits the receiver granted, described in two-level flow control, and the rate itself is capped by whichever of the window and the peer’s credit is smaller. The rate is the window divided by the smoothed round-trip time, so a connection with a window of 120,000 bytes and a round trip of 40 milliseconds paces at roughly 3 MB/s, or about two 1,200-byte packets per millisecond. Packets are then spaced by that interval rather than sent back to back.
The implementation question is how to schedule without a timer per packet. A common approach keeps a next-send timestamp and, on each send opportunity, computes how many packets the elapsed time permits and sends that many, advancing the timestamp by the interval per packet. This converts the schedule into arithmetic and avoids the overhead of a timer that fires thousands of times per second. The granularity of the underlying clock then bounds how accurately the spacing can be honoured, and implementations typically allow a small burst of a few packets to absorb that granularity.
Pacing interacts with aggregation. Because a QUIC packet can carry frames for several streams, a sender that has a full window to send can pack data densely and reduce the per-packet overhead, but it must not pack so much that a single loss removes data for many streams at once. The trade is between header efficiency and the blast radius of a loss, and it is one of the reasons the frame-level retransmission described in loss detection without retransmitted packets is cheap: a lost packet’s frames are re-queued individually rather than as a unit.
Slow start, and the initial window
A connection begins with no estimate of the path. QUIC reuses the initial window formula from the TCP congestion control documents, which starts at a small multiple of the maximum datagram size: ten datagrams when the maximum segment size is at most 1,462 bytes, twelve at up to 2,192, and more for larger sizes. At the 1,200-byte floor QUIC enforces, that is about 12,000 bytes. The window is enough to carry a small response within the first round trip and small enough not to overwhelm a bottleneck that has never seen this connection.
Slow start doubles the window each round trip until a loss occurs or the window reaches a threshold. The doubling is per acknowledgement rather than per round trip, which produces the same effect when acknowledgements are evenly spread, and the result is exponential growth that reaches a large window in a handful of round trips. The cost is that the last doubling before a loss overshoots by roughly the size of the window, so a connection that starts on a high-bandwidth path loses a substantial fraction of its data before it learns the path’s capacity.
The threshold mechanism is what makes the transition from exponential to linear. On the first loss, the sender records the window at that moment, halves it, and thereafter grows by one segment per round trip rather than doubling. A controller that skips this and keeps doubling after a loss will repeatedly overshoot.
Reacting to loss without collapsing
Not every loss means congestion. A packet lost to a random bit error on a wireless link is not evidence that the path is full, and halving the window for it wastes capacity. The basic controllers have no way to tell the difference. A refinement defined for TCP, and recommended in the loss recovery specification for QUIC, is RACK, which uses the time at which later packets were acknowledged to infer which packet was actually lost and when, rather than treating every gap in the packet numbers as an independent loss event.
The distinction that matters most is between a loss and a reordering. A packet that arrives late but arrives is not a loss, and treating it as one halves the window unnecessarily. The packet and time thresholds are set to make this unlikely, and an implementation that declares loss too eagerly on a path with substantial reordering will run at half the rate it could. This is a real cost on paths with multiple parallel links or with load balancers that reorder, and it is why the thresholds are not tunable downward without consequence.
Congestion window validation is the other side. A connection that has been idle for a long period no longer knows the path’s capacity, and continuing at the old window risks a burst into a queue that has since filled with other traffic. Implementations reduce the window after an idle period, and the reduction is bounded so that a connection that pauses briefly does not lose its entire advantage.
ECN as a signal that costs no loss
Explicit Congestion Notification lets a router mark a packet rather than drop it, using two bits in the IP header, as specified in RFC 3168. A QUIC endpoint can negotiate ECN during the handshake and receive the marks back in the IP header of the acknowledgement packets, or in the ACK_ECN frame that reports the counts of marks seen.
The attraction is that a mark conveys congestion without the cost of a retransmission. A sender that receives an ECN mark reduces its window as if a loss had occurred, but no data was lost, so the recovery is immediate rather than one round trip. The complication is that ECN is frequently mangled: some paths clear the bits, some set them when they should not, and some equipment drops packets with the bits set. An implementation must therefore verify that the marks it receives are consistent with what it sent, and disable ECN if the feedback does not match, which is why ECN validation is a state machine of its own rather than a flag.
The validation problem is worse for QUIC than for TCP in one respect: the acknowledgement that carries the mark count is encrypted, so a middlebox that wants to bleach ECN bits has to do so on the IP header alone, without seeing whether the endpoint is counting them. A path that clears the bits on one direction and not the other produces a feedback stream that does not match the sent stream, and the sender must detect the mismatch and fall back rather than interpret the missing marks as an absence of congestion.
What the application can see, and what it cannot
The application sees a byte stream. It does not see the window, the pacing rate, or the number of packets in flight, and the specification does not require an implementation to expose them. What the application does see is the effect: a transfer that starts slow, accelerates for a few round trips, and then settles at a rate determined by the path and by the other traffic sharing it.
A sender that wants to be fair to its own streams has one further decision: how to allocate the connection’s send capacity among them. The transport does not specify this. HTTP/3 implementations typically prioritize by request, and a stream that is blocked on flow control credit is skipped in favour of one that can use the capacity, which is the practical benefit of having the scheduler in the same process as the congestion controller. In TCP the kernel has no idea which bytes belong to which request, so this allocation cannot be made at all.
The cost of owning the socket is that every optimisation must be implemented rather than inherited. A kernel TCP stack has decades of accumulated handling for tinygrams, for receive-window probing, for autotuning against memory pressure, and for the interaction between the send queue and the device queue. A QUIC implementation starts with none of it, and the ones that perform well are the ones that have rebuilt the parts that matter. That is the trade the design makes: control over the schedule, in exchange for responsibility for it.