Skip to content

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.

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.

ss -tin | grep -E 'cwnd|rtt'
# cwnd:96   rtt:1.2/1.4/1.9    <- tiny, bouncing

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.