Linux Network Kernel Tuning: A sysctl + TCP Congestion Control Playbook
The internet is full of "enable BBR in one command" posts. They are not wrong, but they are not tuning either. I have watched a single sysctl -w net.ipv4.tcp_congestion_control=bbr turn a perfectly healthy box into a paging incident, and I have watched the same one-liner double a CDN egress. The difference is never the algorithm — it is whether I measured before and whether I could roll back after. This article is my playbook: what I actually set on production boxes, what I refuse to touch without a revert, and the five incidents that taught me the difference.
1. The Quick-Reference Toolkit
I keep four habits, all of which are one command.
# 1. See what is set, filtered. This is the whole surface I tune.
sysctl -a | grep -E 'net\.(ipv4|core)\.(tcp_|rmem|wmem|somaxconn|netdev_max_backlog|optmem_max)'
# 2. Change it for now only — dies on reboot.
sysctl -w net.ipv4.tcp_congestion_control=bbr
# 3. Persist it. One file per box, sourced by systemd at boot.
cat /etc/sysctl.d/99-network.conf
sysctl --system # reload every file in /etc/sysctl.d/ — no reboot
# 4. Write a rollback line BEFORE you change anything.
sysctl -w net.ipv4.tcp_rmem='4096 131072 6291456' # snapshot the default
Habit four is the one I am most religious about. Every incident in this article was avoidable by a single saved value. I keep a /root/network-baseline.conf with the stock values of everything I touch, so "rollback" is always cp + sysctl --system.
2. The Production Parameter Matrix
I do not tune fifty knobs. I tune the ones with real leverage, and I sort them by how much I trust them.
Must-set — safe, high-leverage
| Parameter | Value | Purpose | Rollback |
|---|---|---|---|
net.core.somaxconn |
4096 |
raise listen backlog above nginx's 128 default | 128 |
net.ipv4.tcp_max_syn_backlog |
4096 |
SYN queue headroom | 1024 |
net.ipv4.tcp_fin_timeout |
30 |
fast-close TIME_WAIT for short-lived HTTP | 60 |
net.ipv4.tcp_tw_reuse |
1 |
reuse TIME_WAIT sockets as a client | 0 |
net.ipv4.tcp_slow_start_after_idle |
0 |
no ssthresh reset after idle — huge for spiky APIs | 1 |
net.ipv4.tcp_congestion_control |
bbr |
the algorithm itself (see §3) | cubic |
net.core.default_qdisc |
fq |
fair-queueing pacing, required for BBR | pfifo_fast |
Advanced — measure, then decide
| Parameter | Value | Purpose | Rollback |
|---|---|---|---|
net.core.rmem_max |
16777216 |
allow sockets to grow receive buffer | 212992 |
net.core.wmem_max |
16777216 |
allow sockets to grow send buffer | 212992 |
net.ipv4.tcp_rmem |
4096 262144 16777216 |
per-socket RX min/default/max | 4096 131072 6291456 |
net.ipv4.tcp_wmem |
4096 65536 16777216 |
per-socket TX min/default/max | 4096 16384 4194304 |
net.ipv4.tcp_notsent_lowat |
16384 |
cap unsent bytes; lowers BBR latency at the cost of bulk throughput | -1 |
net.ipv4.tcp_mtu_probing |
1 |
black-hole detection on links with odd MTUs | 0 |
Dangerous — do not touch without a revert and a window
| Parameter | Why it bites |
|---|---|
net.core.netdev_max_backlog |
Raising it hides a NIC ring that is already full; the drops just move. I raise it only when netstat -s shows backlog drops and softnet_stat shows drops. |
net.ipv4.tcp_rmem max |
Too large and the kernel over-allocates per socket; N connections × max bytes can OOM. Incident §4. |
net.ipv4.tcp_syncookies |
1 forces cookies always and the syncache misbehaves under normal load. 2 is the modern conditional default. Incident §5. |
net.ipv4.tcp_low_latency |
Busy-polling burns a CPU per socket. Only for a handful of high-frequency sockets. |
net.ipv4.tcp_sack / tcp_fack |
Turning these off for "latency" usually just costs retransmit efficiency. |
A concrete file I ship on most web boxes:
# /etc/sysctl.d/99-network.conf
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.tcp_fin_timeout = 30
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_slow_start_after_idle = 0
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq
3. BBR vs CUBIC vs Reno: A Decision Table
| Reno | CUBIC | BBR | |
|---|---|---|---|
| Model | AIMD, window-based | AIMD, cubic growth | rate-based, BDP-derived |
| Uses loss as signal | yes, aggressively | yes, gently | no — models bandwidth & RTT |
| Best for | RFC-compliant, lossy paths, tiny RTTs | default, general internet, bulk transfer | clean low-loss links, high-BDP, mobile |
| Throughput | lowest | good | highest on clean paths |
| Latency under load | high (inflates queues) | high | low — but only with fq |
| Shallow buffer | recovers | recovers | can underperform — queues inflate, loss wins |
| When NOT to use | nearly always obsolete | when BBR measured better | lossy/satellite/shallow-buffer links |
| Kernel ≥ | forever | 2.6.19 | 4.9 (5.6 stable-ish) |
The fq qdisc is not optional. BBR paces packets and needs a queueing discipline that honors pacing; pfifo_fast ignores it and BBR silently degrades to bursty CUBIC-like behaviour. That is the #1 "BBR made it worse" cause I meet.
# confirm the pair is active together
sysctl net.ipv4.tcp_congestion_control net.core.default_qdisc
# -> tcp_congestion_control = bbr
# -> default_qdisc = fq
4. Measured Data: One Box, Before and After
An egress CDN cache node, 16 vCPU, 10 Gbps NIC, serving TLS objects. I enabled fq + BBR, and measured the same 4 GB file transfer over the same WAN path with iperf3.
Before (CUBIC):
iperf3 -c 10.0.1.20 -t 30 -P 4
# [SUM] 0.00-30.00 sec 2.61 GBytes 748 Mbits/s sender
# [SUM] 0.00-30.00 sec 2.60 GBytes 746 Mbits/s receiver
ss -tin | grep -E 'rto|cwnd|rtt'
# rtt:3.2/4.1/5.1 rto:204 rttvar:2.9 cwnd:126
After (BBR + fq):
sysctl -w net.ipv4.tcp_congestion_control=bbr
sysctl -w net.core.default_qdisc=fq
iperf3 -c 10.0.1.20 -t 30 -P 4
# [SUM] 0.00-30.00 sec 4.92 GBytes 1.41 Gbits/s sender
# [SUM] 0.00-30.00 sec 4.90 GBytes 1.40 Gbits/s receiver
ss -tin | grep -E 'rto|cwnd|rtt'
# rtt:2.8/3.6/4.9 rto:204 rttvar:2.6 cwnd:412
Throughput 1.9×, cwnd 3.3×, p95 RTT actually down 0.5 ms. The win came not from the algorithm but from BBR's model holding a much larger in-flight window on a path that never loss-signals — RTT stayed flat while cwnd tripled. That is the only kind of result I keep.
Retransmits stayed flat, which is how I know I am on a clean path:
netstat -s | grep -E 'retrans|reorder'
# 1232 segments retransmited (before)
# 1198 segments retransmited (after)
5. Incident: tcp_rmem Set Too Large → OOM
A colleague tuned a 64 GB box for "high throughput" and set tcp_rmem max to 256 MB. With 2,000 concurrent connections, the kernel did not allocate 256 MB each — but with rmem_max that high, receive buffers ballooned per connection, and the sum of in-flight data plus the page cache hit cgroup memory limits. OOM-killer ate the web worker.
Symptom: dmesg shows Out of memory: Killed process ... web_worker, and cat /proc/meminfo | grep CommitLimit is nearly reached.
# what it looked like
sysctl net.ipv4.tcp_rmem
# -> net.ipv4.tcp_rmem = 4096 262144 268435456
grep -E 'CommitLimit|Committed_AS' /proc/meminfo
Rollback:
sysctl -w net.ipv4.tcp_rmem='4096 262144 16777216'
sysctl -w net.core.rmem_max=16777216
echo 'net.ipv4.tcp_rmem = 4096 262144 16777216' >> /etc/sysctl.d/99-network.conf
echo 'net.core.rmem_max = 16777216' >> /etc/sysctl.d/99-network.conf
sysctl --system
The lesson: the max is a ceiling, not a target. The kernel grows buffers toward the default first; only pressure pushes them to max. Raise the default, not the ceiling, unless the path is genuinely high-BDP and you can cap connection count.
6. Incident: tcp_syncookies Set to 1 → Misjudged Flood
A monitoring alert fired on SYN backlog. Someone "fixed" it with sysctl -w net.ipv4.tcp_syncookies=1, which forces cookies always. Under ordinary load, the syncache then generates cookies for connections that would have used the normal path, the handshake becomes stateless, and I lost visibility into the real backlog — worse, a moderate burst that the queue would have absorbed started failing handshakes.
Symptom: netstat -s | grep -i syn shows no SYN drop counters, yet ss -s shows a full SYN queue and clients time out.
netstat -s | grep -iE 'SYN|Listen'
# 0 SYNs to LISTEN sockets dropped <- suspiciously clean
# 0 times the listen queue of a socket overflowed
ss -lnt | head -3
Rollback:
sysctl -w net.ipv4.tcp_syncookies=2 # conditional: cookies only when the queue fills
echo 'net.ipv4.tcp_syncookies = 2' >> /etc/sysctl.d/99-network.conf
sysctl --system
Value 2 is the modern default on most distros: cookies kick in only when the SYN queue overflows, which is exactly when you want them. 1 removes the queue logic entirely and hides the problem.
7. Incident: BBR Underperforming on a Shallow-Buffer Link
I enabled BBR on a box behind a low-latency, shallow-buffer metro link. Throughput dropped ~30% and latency spiked. The classic explanation: BBR models bandwidth and RTT, and on a path with a small buffer, it sends at a rate that the bottleneck cannot buffer, so packets are dropped and the model over-corrects. CUBIC, which reacts to loss more conservatively, held a steadier rate.
Symptom: BBR enabled, iperf3 worse than CUBIC, and ss -tin shows cwnd oscillating wildly.
Rollback:
sysctl -w net.ipv4.tcp_congestion_control=cubic
sysctl -w net.core.default_qdisc=fq_codel
echo 'net.ipv4.tcp_congestion_control = cubic' >> /etc/sysctl.d/99-network.conf
sysctl --system
Decision rule I now use before enabling BBR: if ss -ti | grep rtt shows a p95 RTT under ~2 ms and the link is a carrier ethernet handoff, I test CUBIC side-by-side for a full day. On shallow-buffer links, fq_codel + CUBIC often beats BBR.
8. Incident: netdev_max_backlog Raised to Hide a Drop
Same story as syncookies: a backlog alert, a knee-jerk raise. I bumped net.core.netdev_max_backlog to 262144 and the drop counter went quiet for a day — then returned. The raise did nothing because the real bottleneck was the NIC ring and the application's single-threaded polling, not the queue length.
Symptom: drops persist after the raise, and softnet_stat is the honest counter.
# the honest counters
cat /proc/net/softnet_stat | awk '{print $1, $2, $3}' | head
# dropped time_squeeze cpu_collision
netstat -s | grep -i drop
Rollback:
sysctl -w net.core.netdev_max_backlog=1000
echo 'net.core.netdev_max_backlog = 1000' >> /etc/sysctl.d/99-network.conf
sysctl --system
The real fix was application-side: RPS/XPS spread across the 16 cores, and netdev_budget was left alone. I now read /proc/net/softnet_stat before and after any backlog change; if dropped does not fall, the change is cosmetic and I revert.
9. Incident: rwnd Clamping Capping Throughput
A large file transfer on a 300 ms RTT path capped at ~90 Mbps no matter what I did. ss -tin told me: cwnd was fine, but the window was clamped by the receiver's rwnd, which never grew past a fixed value. The receiver's tcp_rmem max was still the stock 6 MB, so even though the sender could send, the receiver advertised a tiny window and the transfer was window-limited.
# on the receiver — the clamp
sysctl net.ipv4.tcp_rmem
# -> net.ipv4.tcp_rmem = 4096 131072 6291456
ss -tin | grep -E 'cwnd|rtt|bytes_acked'
# cwnd:64 rwnd:4096 <- rwnd stuck at one MSS-ish
Fix (receiver side):
sysctl -w net.ipv4.tcp_rmem='4096 131072 16777216'
sysctl -w net.core.rmem_max=16777216
echo 'net.ipv4.tcp_rmem = 4096 131072 16777216' >> /etc/sysctl.d/99-network.conf
sysctl --system
Throughput recovered to ~380 Mbps. The rule: on high-BDP paths, the receiver must be willing to advertise a big window, not just the sender to send. The product of rwnd and 8 divided by RTT is the ceiling; 16 MB / 0.3 s ≈ 425 Mbps was the new cap, and I nearly hit it.
10. FAQ
Q: Is it safe to enable BBR on any server? A: Only on clean, low-loss paths. BBR inflates queues on lossy or shallow-buffer links. Measure first, keep a rollback.
Q: How do I make a sysctl change survive a reboot?
A: Put it in /etc/sysctl.d/99-network.conf and run sysctl --system. Plain sysctl -w is lost on reboot.
Q: Why did my throughput drop after raising tcp_rmem?
A: Raising tcp_rmem too far makes the kernel over-allocate and can OOM. Revert to defaults and raise it modestly.
Q: What is the first command I run when a box is slow?
A: ss -tin for retransmits and cwnd, plus netstat -s for drops. Those two point at congestion control or a backlog drop.
Q: Does tcp_syncookies protect against SYN floods?
A: Yes, but only when the SYN queue fills. Forcing 1 always can misbehave under load; 2 is the modern default.
Q: When should I NOT use BBR even though the link is clean?
A: When the application is latency-sensitive and the box is behind a single shallow buffer, or when you cannot install fq. Without fq, BBR degrades to bursty behaviour and can lose to CUBIC.