Why a 208 KB Buffer Throttled My Tailscale Backups

My offsite backups uploaded at a crawl over Tailscale. Every obvious cause was innocent — the culprit was one 208 KB kernel receive buffer dropping packets.

On this page
  1. The shape of the clue
  2. Watching the thing itself, not a proxy
  3. Why 2% loss costs you 90% of your speed
  4. Why the buffer was too small — and why the daemon knew
  5. The fix, and the one non-obvious catch
  6. What the whole hunt was really about

For the better part of a year, pushing a backup to my offsite Proxmox Backup Server ran at about a quarter of a megabyte a second. Reading one back from the same box ran twelve times faster. I’d filed it under “the internet is slow sometimes” and moved on, which is a comfortable lie to tell yourself right up until the day you need those backups to actually leave the building before morning.

So I finally sat down to find the real number and the real reason. The number was worse than I remembered — about 260 KB/s writing out, against 3.1 MB/s reading back. The reason was not my internet, my disks, my RAID array, or any of the five things I was sure it would be. It was a single kernel setting, 208 kilobytes wide, quietly dropping one packet in fifty. Here’s the whole hunt.

First: make these values your own

This post debugs my setup: a Tailscale tunnel between two sites, an offsite NAS receiving NFS writes, and a ~46 ms round-trip between them. Your numbers, service names, and the way you make a sysctl survive a reboot will differ. Placeholders to change if you copy anything below: the buffer value 7500000, the service name tailscaled, the measured rates, the round-trip time, and any path. If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.


The shape of the clue

The first thing worth doing was to look at the asymmetry instead of averaging it away. Writes out were slow; reads back were fine. That single fact quietly rules out a whole category of causes. A slow circuit, a congested link, a bad cable — those would hurt both directions. Something that hurts only the direction flowing into the far end is a much smaller list, and I ignored it for months because “slow upload” pattern-matches to “slow internet.”

It doesn’t, necessarily. So I stopped theorising and measured every suspect, one at a time, refusing to move on until each had an actual number next to it.

Five suspects, measured. One culprit.The internet circuitRaw upload to a neutral endpoint: 2.8 MB/s — 10× the write rateINNOCENTThe NFS exportAlready async, no_wdelay — no server-side write delay to blameINNOCENTThe NAS diskLocal write with fsync: 141 MB/s to the array — 500× the write rateINNOCENTRAID resync after a rebootArray clean, all disks up, no rebuild runningINNOCENTClient TCP / NFS tuningBigger TCP windows, more RPC slots, nconnect — all made zero differenceINNOCENTnet.core.rmem_max = 208 KBClamps the VPN’s UDP receive buffer 34× too small — inbound packetsoverflow and drop before they’re ever decrypted.GUILTY

Every innocent verdict below is a real measurement, not a hunch I talked myself out of:

  • The circuit. Raw upload from the cluster to a neutral speed-test endpoint clocked 2.8 MB/s — ten times faster than the backup write. The pipe was never the problem.
  • The NFS export. Already set to async,no_wdelay; the server wasn’t sitting on writes.
  • The disk. A local write-plus-fsync on the NAS hit 141 MB/s to its RAID array. The storage could absorb writes five hundred times faster than they were arriving.
  • The RAID. Freshly rebooted, but the array was clean and healthy — no resync stealing throughput.
  • Client tuning. I threw the usual NFS-over-WAN knobs at it: larger TCP windows, more RPC slots, nconnect for parallel connections. Nothing moved. Not a kilobyte.

That last one is the tell. When every performance knob you turn does nothing, you’re not under-tuned — you’re bottlenecked somewhere the knobs don’t reach. The problem was below NFS, below TCP, underneath the tunnel itself.


Watching the thing itself, not a proxy

The tunnel is Tailscale, which carries traffic inside WireGuard — and WireGuard runs over UDP. That’s the detail everything turns on. TCP retransmits lost packets and politely slows down; UDP just drops them and moves on. If packets were being lost inside that UDP flow, nothing above it would report an error. It would just be slow.

So instead of theorising, I watched the one counter that would actually tell me: the kernel’s tally of UDP packets dropped because their receive buffer was full. On Linux it’s RcvbufErrors, readable straight from /proc/net/snmp:

Watch the UDP receive-buffer drop counter on the receiving host

# total UDP datagrams dropped for lack of receive-buffer space
netstat -su | grep -i "receive buffer errors"

# or read the raw counter and watch it move during a transfer
grep Udp /proc/net/snmp

The number was 286,066 — and, more damningly, it climbed by 1,155 during a single 15 MiB test write. That’s roughly 2% of the inbound packets being dropped, live, while I watched. Not once at startup. Continuously, every time data flowed in.

There was the culprit, caught in the act. The receiving side was throwing away one packet in fifty before it could be decrypted — and it was doing it because its socket receive buffer was full.


Why 2% loss costs you 90% of your speed

Here’s the part that makes this worth a whole post rather than a one-line fix: a 2% drop rate is not a 2% slowdown. It’s catastrophic, and there’s a clean bit of theory that says exactly how catastrophic.

The Mathis model — from a 1997 paper on how TCP behaves under loss — gives a back-of-envelope ceiling for TCP throughput:

rate ≈ (segment size / round-trip time) × (1 / √loss)

Look at that square root. Loss doesn’t hurt linearly; it hurts by its square root, which punishes small loss rates savagely on anything but a LAN. Plug in my numbers — a ~1400-byte segment, a 46 ms round-trip, 2% loss — and the model predicts very roughly 200–230 KB/s. My measured write rate was 260 KB/s. That’s not a precise prediction and I won’t pretend it is, but it lands in the same small room as the measurement, which is all you need to say “yes, packet loss on this link would produce exactly this crawl.”

The same packets, two receive buffersnet.core.rmem_max = 208 KBWireGuardsender208 KBdecrypt→ disk≈ 1 in 50 dropped≈ 260 KB/snet.core.rmem_max = 7.5 MB (after the fix + tailscaled restart)WireGuardsender7.5 MBdecrypt→ disk≈ 3 MB/sA 2% drop rate isn’t a 2% slowdown. Over a 46 ms tunnel, it’s a 10× one.Reads were always fine: a receive buffer gates data coming in, never data going out.That one-directional asymmetry is the fingerprint of a buffer problem, not a bandwidth one.

And notice what this finally explains: the read/write asymmetry that started the whole hunt. A receive buffer only ever gates data coming in. Reads — data the NAS sends — never touch it, so they sailed along at 3 MB/s the whole time. Writes overflowed it and got shredded. One undersized buffer, and the exact fingerprint I’d been staring at for a year.


Why the buffer was too small — and why the daemon knew

A UDP socket’s receive buffer has a hard ceiling: net.core.rmem_max. When a program asks for a bigger receive buffer with setsockopt, the man page is explicit that “the maximum allowed value is set by the /proc/sys/net/core/rmem_max file” — ask for more and the kernel silently clamps you to that ceiling. On this NAS, that ceiling was the ancient default: 212992, about 208 KB.

The thing asking for a bigger buffer was tailscaled itself. Its own source code sets a target of socketBufferSize = 7 << 207 MiB — with a comment that reads, almost wearily, that the kernel “will silently clamp the value.” Which is exactly what happened: the daemon asked for 7 MiB, the kernel handed it 208 KB, and 34× too little buffer meant every burst of inbound backup traffic overflowed it.

An honest wrinkle worth naming

Tailscale does try to punch through this. Its socket code first attempts the privileged SO_RCVBUFFORCE option, which “can overcome the limit of net.core.{r,w}mem_max, but require[s] CAP_NET_ADMIN,” and only “falls back to the portable implementation… which may be silently capped.” On my NAS the daemon was clearly getting the capped fallback — the drop counter proves it, whatever the reason (a restricted app sandbox, an older build). I didn’t over-theorise past what I could measure: the packets were dropping, and the buffer was the size of the cap.


The fix, and the one non-obvious catch

The fix is two lines. Raise the ceiling well above what tailscaled asks for, then restart the daemon — and that second step is the one that trips people, because skipping it makes the fix look like it doesn’t work:

Raise the ceiling, then re-create the socket

# 7.5 MB — comfortably above tailscaled's 7 MiB request
sysctl -w net.core.rmem_max=7500000
sysctl -w net.core.wmem_max=7500000

# the buffer size is fixed when the socket is created,
# so the daemon must make a NEW socket to pick up the new ceiling
systemctl restart tailscaled

I proved that catch the hard way: I raised rmem_max first, re-measured, and got exactly the same 260 KB/s. The buffer hadn’t changed, because — as the socket man page implies and the kernel enforces — a socket’s buffer is sized once, when it’s created. The already-open UDP socket kept its 208 KB clamp until tailscaled tore it down and built a new one. Raising a limit doesn’t resize existing sockets; only re-creating them does.

Restarting the tunnel cuts the branch you're sitting on

If you administer the box through the tunnel you’re restarting — as I do — restarting tailscaled drops your own connection mid-command. Do it on the console, over the LAN, or with an out-of-band path ready, and treat it as a small maintenance window, not a casual one-liner. And if your host has a RAM-backed /etc (many appliances and NAS units do), a sysctl -w evaporates on reboot — you’ll need to re-apply it at boot or it silently reverts to 208 KB the next time the power blinks.

The expected payoff is roughly 10× — from 260 KB/s up toward the 3 MB/s the read path already proves the link can carry. Past that, the next ceiling is almost certainly the NAS’s modest CPU doing WireGuard’s encryption in userspace, but that’s a different post and a much happier problem to have.


What the whole hunt was really about

I could have “fixed” this a year ago by pasting a sysctl someone recommended on a forum. It might even have worked. But I wouldn’t have known why, which means I wouldn’t have known it was the receive buffer specifically, wouldn’t have understood the restart catch, and wouldn’t have been able to tell you the read/write asymmetry was the clue all along.

The method is the transferable part, and it’s the same one behind measuring what a restore actually costs: measure the thing itself, not a proxy for it. I didn’t find this by reasoning about bandwidth or reading blog posts about NFS tuning. I found it by watching a single drop counter tick upward in real time while data flowed — the one number that was actually describing what was wrong. Everything else was a story I was telling myself. The counter was the truth.

Slow one direction and fast the other? Don’t average it, don’t tune blindly, and don’t assume it’s the internet. Go find the counter that describes your specific symptom and watch it move. The buffer, the disk, the drop — one of them is telling the truth, and it’s usually not the one you’d bet on.


Related posts:

Comments

Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.