DEV Community

Milos
Milos

Posted on

curl works, the browser doesn't: an MTU bug that only breaks QUIC

If traffic through your tunnel works fine under curl but pages crawl or hang in Chrome, stop looking at your proxy and look at your MTU. There's a specific failure here that's easy to miss for a long time, because the standard MTU workaround fixes TCP completely and does nothing at all for QUIC.

The symptom

Everything you test by hand works. curl pulls pages. wget is fine. Your health checks are green, your latency graphs are flat, your error rate is zero.

Then a real browser loads a real site and it takes 30 seconds, or never finishes.

Why your metrics look perfect

Because the failure happens before anything reaches the thing you're measuring.

When a packet is too big for the path and the "don't fragment" bit is set, it gets dropped and an ICMP "fragmentation needed" message is supposed to come back so the sender can shrink. That's Path MTU Discovery. In practice ICMP is filtered all over the public internet, especially on mobile carriers, so the message never arrives. The sender doesn't learn anything. It just keeps retransmitting a packet that will always be dropped, and the connection stalls rather than fails.

A stall is worse than an error for debugging, since nothing anywhere logs it. From your side the request simply never happened.

Why curl passes and the browser dies

Most tunnels ship with MSS clamping turned on, which rewrites the TCP handshake so both ends agree on a smaller segment size up front. It's a good fix and it works.

It also only works on TCP.

QUIC runs over UDP. There's no handshake option to rewrite, so your clamp does nothing. Chrome tries QUIC first against any server that advertises HTTP/3, which is now most large sites. So the browser takes the one path your workaround doesn't cover, while every command line tool you reach for takes the path it does.

That's the whole trap. The tool you debug with is the tool that can't reproduce it.

What the numbers looked like

Same tunnel, same route, same endpoint, one config value changed:

Client MTU Result
1420 0 of 6 parallel streams completed
1360 33 Mbps

Not degraded at 1420. Zero. Six parallel transfers, none of them finished.

1420 is WireGuard's default, and it's a sensible one: 1500 byte Ethernet minus 80 bytes of WireGuard overhead. The assumption baked into it is that you have a full 1500 underneath. Over a mobile carrier, or through PPPoE, or inside a second layer of encapsulation like IPsec, you don't. Every extra layer takes its bytes off the top, and the default stops fitting.

How to find your real number

Send progressively larger pings with fragmentation forbidden and watch for where they start disappearing.

# Linux, payload size, add 28 bytes for the IP and ICMP headers
ping -M do -s 1372 -c 3 1.1.1.1     # 1372 + 28 = 1400
ping -M do -s 1392 -c 3 1.1.1.1     # 1420
Enter fullscreen mode Exit fullscreen mode
# Windows
ping -f -l 1372 1.1.1.1
Enter fullscreen mode Exit fullscreen mode

Walk the size down until the replies come back, add 28, and that's your path MTU. Then set your tunnel's MTU below it, because the tunnel's own overhead comes off that number too. If you'd rather not do arithmetic under pressure, 1360 is a boring value that survives most carrier paths.

The actual fixes, in order

  1. Set the client MTU explicitly. This is the real fix. It's the only one that covers UDP and QUIC.
  2. Keep MSS clamping anyway. It costs nothing and it catches TCP for clients whose MTU you don't control.
  3. Don't treat the clamp as the fix. If clamping is all you have, you've built a system that passes every test you run and fails for every user on Chrome.
  4. Test with a browser, or with something that speaks HTTP/3. curl --http3 if your build supports it. A green curl proves less than it looks like it proves.

You probably don't need to care

If you're not running traffic through a tunnel, none of this will ever touch you. It shows up in VPNs, WireGuard and OpenVPN setups, proxies, container overlays, and anywhere a cloud provider quietly hands you a 1450 byte path instead of 1500.

The general lesson is worth more than the specific number though. When a workaround covers one protocol completely, it will hide the underlying bug until something arrives that speaks the other one. QUIC is that something, and it's already the default in the browser your users are holding.

We build software that runs traffic over exactly these tunnels, at pocketproxy.io, so weigh that as you like. The MTU arithmetic is the same whoever's tunnel it is, and it'll be the same next year too.

Top comments (0)