Path MTU Discovery can settle on a packet size that suffers persistent low-rate loss, and there is no way to cap the search
Summary
On some paths, packets above a certain size are not dropped but delivered imperfectly: in the case I debugged over several weeks, everything above a 1440-byte IP packet suffers ~1% loss while smaller packets see 0%. DPLPMTUD validates the larger size anyway: each probe is acknowledged with 99% probability, and a size is only abandoned after several consecutive lost probes (3 in quic-go), so a 1%-lossy size escapes the search with probability 0.01³ = 10⁻⁶ — validation is effectively guaranteed. Loss-based congestion control then reads the residual loss as congestion. Result: HTTP/3 throughput collapses ~10× and stays collapsed for the life of the connection.
I believe this failure mode is undetectable by probing, even in principle (evidence below, including a re-validation/black-hole-detection prototype I built and field-tested). The only robust remedy I found is capping the discovery ceiling — and quic-go currently has no way to do that: InitialPacketSize only raises the floor, and DisablePathMTUDiscovery pins packets all the way down at 1252/1232. There is nothing between "probe to 1452" and "don't probe at all".
Environment
- Server: quic-go (HTTP/3 via Traefik) on Linux, residential fiber (Telecom Italia), egress interface MTU 1500.
- Client: laptop on Vodafone Italy mobile (IPv4).
- Symptom: HTTP/3 downloads run at ~5 MiB/s for ~10 seconds, then drop to ~500 KiB/s and stay there. Disabling HTTP/3 (falling back to HTTP/2 over TCP) restores full speed on the same path.
Measurements
1. A/B test by packet size (same path, minutes apart; instrumented loss counting per ~50-packet window):
| Server sends | Loss per window | Throughput |
|---|---|---|
| ≤ 1440-byte IP packets (server if MTU set to 1400) | 0/58, 0/55, 0/52, 0/57 … — 0% | ~5 MiB/s sustained |
| 1469-byte IP packets (PMTUD-discovered: 1441-byte UDP payload) | 2/54, 1/50, 5/57, 1/51 … — ~1–1.5% | ~500 KiB/s |
The only changed variable is packet size. Smoothed RTT is a stable ~50 ms in both states (no bufferbloat signature).
2. The threshold is invisible to DF probing. ping -4 -M do on the same path passes at 1450-byte packets (0% loss over repeated runs) and gets a hard 100% drop at 1451 — DF is honored. Yet capping QUIC at 1450 did not fix the collapse; capping at 1440 did. So even conservatively capping discovery at a measured path MTU is not sufficient — the loss threshold sits below what any DF-based measurement reports.
3. Server-side capture confirms delivery, not blackholing. tcpdump shows the outgoing 1469-byte datagrams leave with DF set and ECT(0), and the client's ACKs come back — the oversized packets are overwhelmingly delivered. The discovery ladder (1366 → 1409 → 1430 → 1441) was acknowledged at every step and completed normally.
4. I built the "reduce half" of RFC 8899, and it cannot see this. Suspecting the missing §4.3 machinery, I prototyped and deployed: periodic re-validation probes at the validated size, a downward ceiling ratchet, and a leaky-bucket loss-rate detector over real data packets. Field result on the affected path: the re-validation probes were acknowledged every time (lost-probe counter stayed 0 across 20+ seconds of observation windows), and per-window data loss (~1%) stayed far below any threshold that wouldn't also fire on ordinary congestion. Throughput remained collapsed while every detector read "path healthy". A probe that succeeds 99% of the time will always validate (rejection would require 3 consecutive lost probes); a 1% loss rate is indistinguishable from mild congestion by any loss-rate detector.
5. This is not specific to quic-go's implementation. I also read nginx's QUIC/DPLPMTUD code for comparison: same structure (isolated padded PING probes excluded from loss accounting via ignore_loss, validated MTU strictly monotonic non-decreasing, no loss-vs-size correlation anywhere). It would fail identically on this path. This looks like a structural blind spot of DPLPMTUD as commonly implemented, not a quic-go bug — which is why I think the right fix is an operator-facing cap rather than smarter detection.
What I did not determine (for completeness, so the limits of the evidence are clear):
- The mechanism producing the size-correlated loss — I suspect fragmentation inside the carrier's GTP tunnel, but I have no client-side or in-path capture to prove it. The size-correlation itself is the directly observed fact.
- Why the uplink ping threshold (1450) differs from the largest size the download tolerates (≤1440) — and I did not verify that the ping client and the QUIC client traverse identical paths.
- Whether any ICMP "Packet Too Big" messages are generated on the path (beyond the DF ping behavior above).
- Any influence of ECN (the server sends ECT(0), the client's packets arrive ECT(1); an ECN-disabled control run was not performed).
The API gap
InitialPacketSizeconfigures the lower end of the probing range (#3385 / #4503).DisablePathMTUDiscoverygives up entirely: 1252/1232-byte packets.- There is no way to express "discover, but not beyond X" — which is the only remedy that works for this failure mode, and would also serve as the mitigation requested in #3955 (a different failure mode — the path MTU dropping mid-connection — but the same missing lever).
What other implementations do
| Implementation | Default max packet size | PMTUD | Upper limit |
|---|---|---|---|
| Google (Chromium/quiche) | 1250-byte payload (kDefaultMaxPacketSize); 1350 for tunnels |
not enabled by default | 1452-byte payload (quic_constants.h) |
| Mozilla (neqo) | 1252/1232 before probing | fixed search table: 1280, 1380, 1420, 1470/2, 1500, … (pmtud.rs) | search table |
| Microsoft (msquic) | starts at 1288-byte IP packet | enabled by default | MaximumMtu = 1500, configurable (Settings.md) |
| Cloudflare (quiche) | 1200-byte payload | disabled by default | 1200 unless configured (Config) |
| quic-go | starts at 1280 | enabled by default | 1452, not configurable |
Notably, every implementation that ships a fixed size chose 1200–1350 bytes (reachability over efficiency), and msquic — the closest analogue to quic-go's probe-by-default posture — exposes its ceiling as a setting.
Possible direction
A Config.MaxPacketSize field mirroring InitialPacketSize at the other end of the probing range. I have a working implementation (#5767, validated in my production environment for a month) which I'm happy to adapt to whatever shape you prefer, or to treat purely as a reference for this discussion. Whether the default ceiling should also come down (1452 assumes clean end-to-end Ethernet; see the table) is a separate question I'd defer to you.
Related: #3955, #4520.
Disclosure: I used AI assistance for parts of the diagnostic tooling and for drafting this text. The measurements, the packet captures, the prototype testing, and the month of production validation are my own work on my own infrastructure.
Source: quic-go/quic-go