When Idle Isn't Idle: How a Linux Kernel Optimization Became a QUIC Bug
How Cloudflare traced a CUBIC death spiral in quiche — where the congestion window locked at its two-packet minimum after loss — to an incorrect port of a 2017 Linux kernel idle-period adjustment.

Congestion control exists to recover. That is its only purpose during a loss event — absorb the damage, back off, then climb back out. A CUBIC implementation that shrinks its window to two packets and never grows it again is not exhibiting slow recovery; it has stopped being a congestion controller entirely.
Source
When “idle” isn’t idle: how a Linux kernel optimization became a QUIC bug
By: Cloudflare (Esteban Carisimo, Antonio Vicente)
Published: May 12 2026
Type: Engineering article
Saved because the bug’s genealogy — a kernel fix correctly identified, selectively ported, then silently undermined by a follow-up commit that was never applied to the port — is a precise, reproducible lesson in what happens when a two-commit fix is treated as one.
How measuring idle from the wrong timestamp locks a QUIC connection forever
CUBIC is the default congestion algorithm in the Linux kernel and in Cloudflare’s open-source QUIC implementation quiche. Its central mechanism is the congestion window (cwnd): a cap on how many bytes can be in flight at once. Under healthy operation CUBIC grows cwnd along a cubic curve anchored by an epoch start timestamp, shrinks it on loss, and resumes climbing from where it left off once the network clears. The epoch timestamp is what preserves continuity — rather than resetting the growth curve after every loss event, CUBIC shifts the anchor to maintain the shape of the curve while accounting for how much time has passed. In a working connection this cycle is self-correcting. In the connection Cloudflare was debugging, it had become permanently self-defeating.
The symptom appeared in a QUIC integration test: a 10 MB download over HTTP/3 with 30% random packet loss injected for the first two seconds, followed by a clean network for the remaining eight. The expected behavior is that CUBIC absorbs the loss phase, reduces its window, then grows it back during the clean phase and completes the download well within a ten-second timeout. Instead, in roughly 60% of 100-trial runs, the download never completed. Visualizing the congestion controller’s state through qlog output revealed what was happening: after the two-second mark, cwnd remained pinned at 2700 bytes — two full-size packets — and the congestion state oscillated between recovery and congestion avoidance 999 times in 6.7 seconds. One state flip every 14 milliseconds, matching the connection’s 10 ms RTT almost exactly.
The RTT-synchronized oscillation period was the first real clue. Something was triggering the recovery/avoidance flip once per round trip, locked to the ACK clock — the rhythm at which the client’s acknowledgements arrived and prompted the server’s next send. At minimum cwnd, every incoming ACK batch drained bytes_in_flight completely to zero before the server sent the next two-packet burst. That zero crossing was the trigger for a code path that was supposed to handle a very different situation.
The code in question was quiche’s port of a 2017 Linux kernel CUBIC fix. In TCP, when an application goes idle and stops sending for a while, CUBIC’s epoch timestamp becomes stale — the growth curve was anchored before the gap, so when sending resumes, delta_t = now - epoch_start is enormous and the algorithm would try to immediately inflate cwnd to an unreasonable value. The kernel fix was elegant: rather than resetting the epoch, shift it forward by the idle duration, preserving the growth curve’s shape but sliding it in time. quiche ported this as a check inside on_packet_sent(): when bytes_in_flight == 0, compute delta = now - last_sent_time and advance congestion_recovery_start_time by that amount.
The problem is that bytes_in_flight == 0 does not mean the connection was idle. At minimum cwnd, it means the entire two-packet window was sent, both packets were acknowledged in a single ACK batch, and the pipe drained between the ACK arriving and the next send. The connection was not idle — it was congestion-limited to such a small window that it emptied on every cycle. Using now - last_sent_time as the idle delta in this regime produces a value of approximately one RTT, because last_sent_time is the timestamp of the previous send, one round trip ago. That RTT-sized delta advances congestion_recovery_start_time into the future on every send. Because the recovery boundary is now ahead of the current time, in_congestion_recovery() returns true for every incoming ACK. Since CUBIC skips all cwnd growth for packets it believes are in a recovery window, the window stays at two packets, the pipe empties again on the next ACK, the delta is computed as another RTT, the boundary advances again, and the cycle repeats without any possibility of escape under normal conditions.
The Linux kernel had already solved this exact problem. A follow-up commit landed one week after the original 2017 patch, noting that because the epoch is set during ACK processing but the adjustment is computed at send time, the delta calculation can push the recovery boundary into the future. The kernel’s correction was to clamp: never allow congestion_recovery_start_time to advance past the current clock. quiche had ported the first commit but not the second, and the bug lay dormant because no existing test drove cwnd to its minimum floor and then checked whether it could recover. The test suite exercised steady-state growth, loss response, and throughput — not the three-way conjunction of exited slow start, congestion avoidance mode, and minimum window that this trap requires.
The fix measures idle duration from last_ack_time rather than last_sent_time. When bytes_in_flight hits zero, it hits zero because ACKs arrived — last_ack_time approximates when that happened. Using max(last_ack_time, last_sent_time) as the idle start gives the actual gap between the last acknowledgement and the next send, which in the minimum-cwnd trap is effectively zero. The recovery boundary stops advancing, the next send lands ahead of it, in_congestion_recovery() returns false, and CUBIC resumes growing cwnd normally. For genuinely idle connections, last_ack_time is far in the past and the same expression still captures the full idle duration — the original epoch-shift behavior is preserved. The fix required changing three lines after weeks of instrumentation, log analysis, and state visualization to find the root cause.
My take
The real lesson here is about patch lineage, not about CUBIC. When you port a fix, you need to port the entire fix — including corrections to the fix — and track the parent commit’s history forward in time, not just at the moment of porting. Two commits that together constitute a correct solution cannot be treated as one commit plus an optional footnote. The second commit exists because the first one was incomplete, and incomplete is another word for wrong.
Where this is useful
- Debugging congestion controller implementations that refuse to grow
cwndafter severe loss, especially at minimum window on connections with a clean network - Understanding why
bytes_in_flight == 0in a user-space QUIC implementation cannot be used as a reliable signal for connection idleness without additional context - Reviewing CUBIC ports from kernel space to user space — kernel TCP relies on callbacks like
CA_EVENT_TX_STARTthat do not exist in user space and must be approximated differently - Not applicable as a diagnosis for connections that recover slowly but do recover — this death spiral locks
cwndpermanently; gradual recovery indicates normal CUBIC backoff, not this bug
Apply this: When porting a kernel congestion control fix to a user-space implementation, find every commit that touched the same code within two weeks of the fix — the original commit is rarely the complete story.
What I want to remember
Remember:
bytes_in_flight == 0means the window drained, not that the connection was idle — confusing the two is what turned a TCP optimization into a QUIC death spiral that locks the congestion window permanently at its minimum.