TCP Tuning on High-Throughput Linux Servers: sk_backlog vs rmem
Default Linux TCP buffer sizes were designed for 100 Mbps LANs. On 10G links with high concurrency, the defaults leave performance on the table. The parameters that matter.

Default Linux TCP buffer sizes assume 100 Mbps LANs and polite concurrency. On 10 Gbps NICs (Intel X710), 400+ concurrent TLS connections, and cross-AZ traffic, we left 30–40% throughput on the table until we stopped tuning random sysctl entries from blog posts and measured.
Symptom
iperf3 between two c6in.large instances in same AZ showed 9.2 Gbps — fine. Application throughput (NGINX → backend gRPC) plateaued at ~4.8 Gbps aggregate with CPU headroom remaining. ss -tin showed large recv-q backlogs on receivers during burst.
Not CPU. Not TLS cipher (AES-GCM hardware accelerated). Socket buffer exhaustion and listen backlog drops.
Kernel: 6.1.0-21-amd64 (Debian 12). sysctl -a | grep tcp had mostly defaults.
Parameters that actually moved the needle
net.core.rmem_max / wmem_max / tcp_rmem / tcp_wmem
Defaults:
net.core.rmem_max = 212992
net.ipv4.tcp_rmem = 4096 131072 6291456
We set:
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 1048576 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
Effect: Application throughput 4.8 → 7.1 Gbps. BBR (below) got us the rest.
Autotuning must be enabled (net.ipv4.tcp_moderate_rcvbuf = 1 — default on). Without raised rmem_max, autotuning caps too low.
net.core.netdev_max_backlog
Default 1000. Under burst, softirq couldn't drain NIC fast enough — dropwatch showed drops in net_rx_action.
Set to 250000 (Confluent Kafka tuning guide influence; validated on our workload). Drops gone. Don't set blindly on memory-constrained hosts — each queued skb consumes memory.
net.ipv4.tcp_max_syn_backlog and somaxconn
Listen backlog drops (ss -lnt showing Send-Q at limit) on NGINX during connection storms.
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 8192
Also required NGINX listen 443 backlog=65535 — kernel limit useless if app requests lower.
sk_backlog vs rmem distinction: sk_backlog (syn backlog, accept queue) is about connections waiting for accept(). rmem is byte buffer per established socket. We needed both; tuning only tcp_rmem didn't fix SYN flood of legitimate mobile clients at market open.
ip_local_port_range
Ephemeral port exhaustion on load generators during soak test — not server-side, but looked like server stall.
net.ipv4.ip_local_port_range = 1024 65535
Default upper bound 60999; raising helped client-side connection churn tests.
net.ipv4.tcp_congestion_control = bbr
BBR v2 not in 6.1 mainline; BBR v1 helped cross-AZ (15 ms RTT) more than same-AZ. Same-AZ gain mostly from buffers.
Cubic default left bandwidth on floor on lossy cross-region link test. BBR not free — bufferbloat in shared tenant environments; we use BBR only on dedicated streaming nodes.
net.ipv4.tcp_slow_start_after_idle = 0
Controversial. Disabling improved latency on idle-then-burst HTTP/2 connections to mobile clients. Throughput win small; tail latency win measurable (p99 -12 ms on cold connection reuse).
Re-enable if fair-queuing matters on shared NICs — we isolated hot services.
Parameters that didn't help (our workload)
| Parameter | Result |
|---|---|
| tcp_timestamps=0 | Broke PAWS protection; no gain |
| tcp_sack=0 | Worse loss recovery |
| Large tcp_window_scaling off | Never do this |
| MTU 9000 (jumbo) on AWS | No benefit without end-to-end jumbo; enabled only on internal bench VLAN |
Validation methodology
We did not trust iperf3 alone after application tuning. Validation stack:
iperf3 -P 16same-AZ baseline- Application soak: NGINX → 40 upstream gRPC pods, 30 min, measure
upstream_response_timep99 ss -ssummary every 60s — watch for TIME_WAIT accumulation- Cross-AZ test on separate node pool with BBR vs cubic A/B
Only promoted sysctl bundle when all four passed.
tcp_fin_timeout
We briefly lowered net.ipv4.tcp_fin_timeout from 60 to 15 during TIME_WAIT investigation — freed ephemeral ports faster on load generators but increased risk of stale connection state on lossy paths. Reverted to 30 as compromise on streaming nodes only.
Measurement commands
# Per-socket queues
ss -tin state established '( sport = :443 )'
# Softnet drops
cat /proc/net/softnet_stat
# Congestion window (requires eBPF or tcp_probe)
# we used bpftrace one-liner on staging
See io_uring edge notes for async I/O path — orthogonal to TCP window tuning but same tuning session.
Network namespace debugging when values "didn't apply": wrong netns. See network namespace routing.
Rollout
- Changed via Ansible role
linux-tcp-tuning, taggedperf - Staged: canary ASG 10% → 50% → 100% over 3 days
- Rollback: single sysctl revert; no app changes
Document every non-default sysctl in /etc/sysctl.d/99-tcp-tuning.conf with comment linking to this runbook — future you will forget why netdev_max_backlog is 250000.
What I'd do next
- Per-service tuning profiles — gRPC long-lived vs HTTP short-lived shouldn't share identical
tcp_slow_start_after_idle - Evaluate BBR v3 when available in our kernel track
- Automate
ssrecv-q alerting in Prometheus node exporter textfile collector
Default TCP on Linux is conservative for good reasons. On high-throughput servers, rmem_max and listen backlogs are not optional — and they're different problems from each other.
Manish Bookreader
Electronics enthusiast, Embedded Systems Expert, Linux/Networking programmer, and Software Engineer passionate about AI, electronics, books, and cooking.

