sysnotes

TCP buffer sizing on high-latency links

2026-09-14 · 6 min read

The bandwidth-delay product is the number people skip, and it is usually the one that explains why a link that benchmarks at 900 Mbit/s on the LAN tops out at 40 Mbit/s across an ocean.

Start from the product

In-flight data is what keeps a TCP connection busy. If the sender is allowed only a small window, it spends most of its time waiting for acknowledgements rather than transmitting. The ceiling is:

throughput_max = window / RTT

A 200 ms round trip and a 256 KB window give roughly 10 Mbit/s, no matter how fast the physical link is. To move 1 Gbit/s at that RTT you need about 25 MB in flight.

The three limits that actually bind

Raising only the first two and leaving the application unchanged is the most common reason "the tuning did nothing".

Autotuning is usually enough

Modern kernels grow the receive window automatically. On a clean link, the default range is fine up to a few hundred Mbit/s. Where it stops working is when the kernel cannot distinguish "the peer is slow" from "the path is long", which is exactly the case on satellite, mobile, and congested transit.

For those paths, a wider autotuning ceiling is worth setting:

net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_rmem = 4096 131072 33554432
net.ipv4.tcp_wmem = 4096 65536 33554432

Why the qdisc matters

BBR estimates bandwidth and RTT from delivery rate rather than treating loss as the only congestion signal, which helps on paths with a few percent loss. But it needs a queue discipline that supports pacing. fq does; fq_codel and pfifo_fast behave differently. Setting the congestion control without the qdisc is a common half-measure.

Measuring it properly

Single-stream iperf3 is the only number that matters here. Parallel streams hide the window limit by opening several connections, each with its own window, which is exactly the workaround you are trying to avoid.

iperf3 -c host -t 30 -O 5

Watch ss -tim on the receiver: cwnd and rwnd stuck well below the configured maximum means the limit is elsewhere — usually the application.