Congestion Window Optimization for CDN: Tuning CWND for Maximum Throughput

Back in 2019, we pushed a routine PoP expansion in Southeast Asia and watched our cache hit throughput tank by 40% during peak hours. It wasn't origin latency, it wasn't packet loss — it was our congestion window. We'd left the defaults untouched, and on 80ms links to eyeball networks, those 10-packet initial windows were starving every short-lived connection before it could ramp up. That incident forced us to rethink how we approach CWND tuning across the entire CDN edge.

Why Default CWND Settings Starve CDN Edge Performance

Most Linux distributions still ship with an initial congestion window (IW) of 10 MSS, roughly 14.6KB with standard Ethernet MTU. That value was chosen a decade ago when memory was tighter and the web was less object-heavy. For a CDN serving 50KB - 250KB assets like images, JS bundles, and API fragments, IW10 means you're forcing almost every transfer into multiple round trips before it can finish. I've seen this repeatedly on modern 4G and fiber eyeballs where the Bandwidth-Delay Product and CDN Tuning: Filling the Pipe Without Overwhelming It is often 100KB to 500KB — you're leaving 80% of the pipe empty during the most critical phase of the connection.

The problem compounds with TCP's slow start behavior. With IW10, your congestion window doubles each RTT until packet loss or ssthresh is hit. On a 60ms RTT link, delivering a 180KB image takes at least 4 RTTs: 14KB, 29KB, 58KB, 116KB. That's 240ms just in congestion window ramp-up, not counting TLS and request processing. We measured this on our own edge in Frankfurt — moving from IW10 to IW32 cut median object delivery time for 100-300KB assets by 28% without any measurable increase in retransmits. The defaults aren't conservative, they're just wasteful for today's access networks.

There's also a common misunderstanding that a larger CWND automatically risks bufferbloat or loss. It doesn't, if you tune it with the path in mind. Throughput maximization isn't about setting CWND to infinity; it's about aligning your initial and steady-state window with the actual BDP of the paths you serve most often. For CDN work, that means analyzing your RTT distribution and bottleneck bandwidth per region, not applying one global sysctl and hoping for the best. Standards around congestion control are well documented in the HTTP Working Group Specifications, but the operational tuning is left to you.

# Inspecting and tuning initial CWND on a CDN edge (Linux 5.15+)
# Check current congestion control and initcwnd per route
ss -i dst 203.0.113.0/24
ip route show | grep initcwnd

# Set a higher initial window per route - far safer than global sysctl
ip route change 203.0.113.0/24 via 198.51.100.1 dev eth0 initcwnd 32 initrwnd 32

# Use BBR for better RTT fairness on mixed-loss paths
sysctl -w net.ipv4.tcp_congestion_control=bbr
sysctl -w net.ipv4.tcp_notsent_lowat=16384
sysctl -w net.ipv4.tcp_slow_start_after_idle=0

# Verify with a test transfer
nstat | grep -i retran
cat /proc/net/netstat | grep TcpExt

Calibrating CWND Against the Real Bandwidth-Delay Product

Effective CWND tuning starts with an honest measurement of BDP, not a guess. I learned this the hard way after we tuned a US East PoP for 10 Gbps interconnects only to realize the last-mile bottleneck to most clients was 25-60 Mbps with 30-45ms RTT. Our BDP wasn't 10 Gbps * 40ms, it was 35 Mbps * 40ms = ~175KB. Setting initcwnd to 64 MSS (93KB) would have been reasonable, but we initially pushed 100 MSS and saw loss spikes on congested mobile carriers. You have to measure the client-facing BDP, not your backbone capacity.

The way we do it now is per-prefix and per-ASN aggregation. Every edge node logs smoothed RTT (srtt from tcp_info) and delivery rate (tcp_info.tcpi_delivery_rate) for completed flows, bucketed by /24 and ASN. That gives us a p50 and p90 BDP map per PoP. For example, our Singapore PoP sees p50 BDP of ~120KB to Indonesian mobile ASNs and ~280KB to Japanese fiber ASNs. We then set differentiated initcwnd values via policy-based routing: 32 for mobile-heavy prefixes, 42-48 for fiber-heavy ones. If you don't have this granularity, start with a single blended value around 32 MSS — it's a solid improvement over 10 for nearly all CDN workloads, as confirmed by performance guidance on web.dev Articles on network optimization.

Don't confuse congestion window with receive window, though they interact. Even if you crank up CWND, a client with a small receive window or without window scaling will cap you anyway. That's why TCP Window Scaling and Its Impact on CDN Throughput: Why Defaults Fall Short matters so much — without scaling negotiated to at least 7-8 bits, your max window is stuck at 64KB regardless of what the sender allows. We routinely see older set-top boxes and IoT clients that negotiate wscale 2 or not at all. For those, tuning CWND beyond 64KB is pointless. Your tuning has to be conditional on what the client actually advertises.

#!/usr/bin/env python3
# Estimate ideal initcwnd from recent tcp_info samples
import statistics

# Collected from `ss -ti` or eBPF: (srtt_ms, delivery_rate_mbps)
samples = [
    (42, 38.5), (38, 45.2), (55, 22.1), (31, 60.0), (47, 35.4),
    (68, 18.9), (29, 72.3), (41, 40.1), (50, 30.5)
]

def bdp_bytes(rtt_ms, mbps):
    return (mbps * 1_000_000 / 8) * (rtt_ms / 1000)

bdps = [bdp_bytes(rtt, bw) for rtt, bw in samples]
p50_bdp = statistics.median(bdps)
p90_bdp = sorted(bdps)[int(len(bdps) * 0.9)]

MSS = 1460
ideal_iw_p50 = round(p50_bdp / MSS)
ideal_iw_p90 = round(p90_bdp / MSS)

print(f"p50 BDP: {p50_bdp/1024:.1f} KB -> IW {ideal_iw_p50}")
print(f"p90 BDP: {p90_bdp/1024:.1f} KB -> IW {ideal_iw_p90}")
print(f"Recommended initcwnd: {min(48, max(32, ideal_iw_p50))} (capped 32-48)")

# Output example for Singapore -> ID mobile:
# p50 BDP: 189.5 KB -> IW 133
# p90 BDP: 267.2 KB -> IW 188  -> capped recommendation is still 48
# Real initcwnd should be a fraction of full BDP (IW covers slow-start only)

Initial Window vs. Congestion Avoidance: Picking the Right Growth Curve

Setting the initial window gets you out of the gate faster, but steady-state CWND behavior determines throughput for larger objects and HTTP/2 or HTTP/3 multiplexed streams. We run CUBIC on most cache nodes and BBR on our high-RTT transit edges, and the difference in how they grow CWND is stark. CUBIC is loss-driven and cubic growth means it probes aggressively after a loss, which is fine on low-loss fiber but brutal on lossy mobile. BBR is model-based, estimating BDP and pacing to it, so it holds a more stable window and recovers faster without the sawtooth collapse. For a CDN, that stability translates directly to more consistent TTFB.

One mistake I see teams make is cranking initcwnd without adjusting ssthresh and congestion avoidance tuning. If you set initcwnd to 32 but leave ssthresh at its default, you may still exit slow start too early and enter congestion avoidance with a linear growth phase that takes several RTTs to fill the pipe. Pair your initcwnd increase with appropriate pacing and initial ssthresh. On Linux, tcp_slow_start_after_idle is another quiet killer — by default, the kernel resets CWND after an idle period. For persistent HTTP keep-alive connections serving many small assets, this means every gap between requests resets your carefully tuned window. We disable it on all edge cache listeners.

Accurate RTT is non-negotiable here. If your RTT measurements are inflated by delayed ACKs, scheduling lag, or coarse timers, your BDP estimate is inflated and you'll overshoot the window. We moved to tcp_info-based RTT sampling with timestamps and filtered out RTT samples during loss recovery. If you're still using ping or synthetic HTTP RTT, read RTT Measurement Methodology for CDN: Getting Accurate Round-Trip Times — we've found kernel srtt to be 10-15ms lower than application-level timing on our stack. The table below shows what we measured when we A/B tested IW values on a single EU PoP serving mixed 50KB-500KB content over 30-70ms RTT paths.

initcwnd (MSS) Median BDP Utilized Avg. Throughput (Mbps) p95 TTFB (ms) Retransmit Rate (%)
10 (default) 38% 28.4 182 0.31
32 71% 42.7 131 0.38
48 84% 46.1 118 0.52
64 88% 46.8 115 0.81

Putting Tuned CWND Into Production: Kernel, Edge Stack, and Per-POP Overrides

Knowing your ideal BDP is one thing — getting it live across a fleet of heterogeneous edge nodes is another. Don't try to push a single global CWND. We've learned the hard way that what works for a well-peered POP in Ashburn with 12ms RTTs will completely bufferbloat a mobile-heavy POP in Mumbai where p95 RTTs swing between 90ms and 180ms. The right approach is template + override. Set a sane global baseline tuned for your median client profile, then apply per-route and per-ASN exceptions where the data tells you to.

For Linux-based edges (which is most of you), that means touching three layers. First, the congestion control algorithm itself. If you're still on CUBIC with an IW10 default, you're leaving 30-40% of throughput on the table for high-BDP clients. BBRv2 or BBRv3 is far more CWND-aware and won't collapse under the transient loss that kills CUBIC. Second, bump initcwnd and initrwnd from the stingy default of 10 MSS. For CDN workloads serving 50KB-2MB objects, IW32 to IW48 is the sweet spot we've validated — it cuts an entire RTT off your Time-to-First-Byte for cache misses without triggering massive burst loss. At 1460 byte MSS, IW32 gives you ~46KB in the first flight, enough to deliver a typical hero image or JS chunk without waiting for ACKs. Third, disable tcp_slow_start_after_idle on your long-lived origin-to-edge connections so you don't re-enter slow start after every 2-second idle gap.

Don't forget to tune per-route. Global sysctl is blunt. Use ip route to set different initcwnd values for your eyeball networks vs. origin fetches. You'll want to monitor TCPRetrans, TCPDSACK, and your edge's tcp_retrans_collapse counters after each change. If retransmits jump more than 0.5% after an IW increase, you've pushed too hard for that path's bottleneck buffer. Roll it back and let BBR probe more conservatively.

# /etc/sysctl.d/99-cdn-cwnd.conf - Baseline for BBR-based edge
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_notsent_lowat = 16384
net.ipv4.tcp_window_scaling = 1
net.ipv4.tcp_slow_start_after_idle = 0
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

# Per-route override: Higher IW for high-BDP eyeball / transit
ip route change 203.0.113.0/24 via 198.51.100.1 dev eth0 initcwnd 32 initrwnd 32
ip route change 192.0.2.0/24 via 198.51.100.1 dev eth0 initcwnd 48 initrwnd 48

# Verify: ss -ti | grep -E 'cwnd|bbr'
# Check retrans rate: nstat -az | grep -i retrans

When Tuning Backfires: Diagnosing Bufferbloat, Jitter, and Reordering Collapse

A bigger CWND isn't always a faster CWND. The most common failure mode we see isn't packet loss — it's bufferbloat-induced latency inflation. You crank IW to 48, your throughput graphs look great on synthetic tests from a datacenter, but real users see p99 TTFB spike by 200-400ms. Why? You've overfilled the shallow buffers on last-mile CPEs and CGNATs. Those devices might only hold 64-128KB of queuing. Blast 70KB in the initial burst and you'll queue for 60ms+ before a single ACK returns. That's why you can't just look at averages. You need to correlate your CWND changes against CDN Percentile Latency Analysis: What p50, p95, and p99 Actually Tell You — if your p50 improves by 15% but p99 degrades by 25%, you've optimized for the wrong percentile.

The other two silent killers are jitter and reordering. A CWND tuned perfectly for a stable 40ms path will misbehave badly when CDN Jitter Measurement and Its Impact on User Experience shows ±15ms variance. CUBIC interprets jitter-induced ACK compression as congestion and halves its window prematurely. BBR handles this better, but even BBR will mis-estimate its BtlBw if ACKs arrive bunched. If your POP's 95th percentile jitter exceeds 20ms, we recommend enabling pacing ( tc qdisc add dev eth0 root fq maxrate 2gbit ) — it smooths your initial window burst from a microburst into a paced flight, cutting burst loss from ~1.2% to under 0.2% in our tests.

Finally, watch for Packet Reordering Effects on CDN Performance: The Hidden Throughput Killer. Aggressive CWND growth with Linux's default tcp_reordering of 3 can trigger spurious fast retransmits on paths with per-packet ECMP hashing — common on Tier-1 backbones. You'll see it as a high rate of DSACKs without actual loss, and your CWND will sawtooth for no reason. If you're seeing >0.3% DSACK rate with near-zero true loss, bump tcp_reordering to 20-30 or enable RACK/TLP loss recovery. We also suggest tracking TCPSACKReneging — it's a telltale sign your receiver's window is collapsing because your CWND is outrunning its ability to reorder.

Frequently Asked Questions

What's a safe starting Initial Window (IW) for a mixed-traffic CDN?

Start at IW32 if you're serving web assets (30KB-500KB) and have BBR enabled with pacing. It's a 3.2x increase over the default IW10 but still safe for most bottleneck buffers. A/B test IW32 vs IW48 per POP. For video origin-to-edge or large file delivery (10MB+), IW48 to IW64 makes sense because you stay in slow start longer. Never push past IW64 without active bufferbloat monitoring — you'll start seeing retransmits >1% on mobile networks.

Should I use BBR or CUBIC for CDN edge tuning?

Use BBRv2/BBRv3 for eyeball-facing edges, especially where RTT variance is high. CUBIC is loss-based — it needs packet loss to find the ceiling, so it consistently overfills buffers and then collapses. BBR is model-based and tracks the BDP directly, so it keeps CWND closer to optimal and recovers faster. Keep CUBIC for origin-facing long fat pipes if your origins don't support BBR, but even there, BBR's probe BW cycle is gentler on shared links.

How do I know my CWND is too large?

Three signals: 1) Retransmit rate climbs above 0.8-1.0% (healthy CDN edge should be 0.1-0.4%). 2) Your p99 latency degrades while p50 improves. 3) You see growing DSACKs and spurious retransmits in ss -ti or tcp_info. If any of those appear within 24 hours of a CWND increase, you've exceeded the path's buffer. Dial back initcwnd by 8-10 MSS or enable fq pacing to de-burst the window.

Does increasing CWND help for small objects under 15KB?

Yes, but only via the Initial Window. For objects that fit in a single flight (IW10 = ~14.6KB), congestion avoidance never kicks in — the transfer finishes in slow start or even in the initial window. Bumping IW to 32 lets a 25KB CSS file or 40KB API JSON response finish in 1 RTT instead of 2, shaving 30-80ms off TTFB. For sub-15KB objects, you're already done in one flight, so further CWND tuning has zero impact. Focus there on TLS and connection reuse instead.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker