TCP Slow Start Impact on CDN Performance: Why First Bytes Are the Slowest

Back in 2018 I was chasing a TTFB regression on a Tokyo PoP that only showed up on cache misses. The edge was healthy, the origin was fast, yet our synthetic monitors kept flagging 180-220ms for a 45KB hero image that should have been ~60ms. It wasn't origin latency or TLS — it was the first few round trips. That was my first real lesson in how brutally TCP slow start punishes the first bytes off a cold connection.

Why Cold Connections Pay a Round-Trip Tax

Every new TCP connection starts blind. The sender has no idea what the path can handle, so it probes with the initial congestion window (IW) and then doubles its congestion window (cwnd) each round trip until it hits packet loss or the slow start threshold (ssthresh). On Linux that default IW10 is 10 * MSS, roughly 14.6KB with a 1460-byte MSS. If your HTML document is 28KB, you’re not sending it in one blast — you’re sending 14.6KB, waiting an RTT for the ACKs, then sending the rest.

I've seen teams burn weeks optimizing image compression while ignoring this. For small objects — and at the edge, most objects are small — slow start dominates. The problem compounds at distance. From New York to Sydney you're looking at 280ms RTT. Sending that same 28KB document takes two round trips just for the TCP payload, before you count DNS and TLS. We measured this live during a rollout where we forgot to tune tcp_slow_start_after_idle on reused origin connections. Idle connections were dropping back into slow start and our origin fetch p99 jumped 35%. Connection warmup isn't a theoretical concept; it's the literal doubling stair-step you see in a packet capture.

The interaction with loss recovery makes it worse. If one of those first packets is lost during slow start, you don't have enough duplicate ACKs to trigger fast recovery. You fall back to a retransmission timeout (RTO), which on many stacks is 200ms-1s. That single loss can dwarf the actual transfer time. This is why understanding your RTT Measurement Methodology for CDN: Getting Accurate Round-Trip Times matters so much — you can't model warmup cost if your RTT numbers are smoothed or inflated by application-layer timing.

# Capture slow start behavior on a cold CDN edge connection
# Filter for the first 20 packets and graph cwnd growth
sudo tcpdump -i eth0 'tcp port 443 and host 203.0.113.50' -w cold_start.pcap

# Visualize cwnd vs. time using ss (run in parallel)
while true; do
  ss -ti '(dport = :443)' | grep -E 'cwnd|rtt'
  sleep 0.01
done

# Typical ss output during slow start:
# cubic wscale:7,7 rto:204 rtt:42.5/11.2 ato:40 mss:1448 cwnd:10 ssthresh:2147483647
# cubic wscale:7,7 rto:204 rtt:42.5/11.2 ato:40 mss:1448 cwnd:20 ssthresh:2147483647
# cubic wscale:7,7 rto:204 rtt:43.1/11.2 ato:40 mss:1448 cwnd:40 ssthresh:2147483647

The Initial Congestion Window Ceiling You've Never Raised

IW10 has been the Linux default since 2011 when Google pushed to raise it from IW3. It was a huge win then, but on today's high-BDP paths it's still conservative. With IW10 you can only put ~14KB on the wire before you must stop and wait. For a modern HTTP/2 or HTTP/3 response where the server pushes headers plus the first data frame, you're already bumping against that ceiling. I worked with a video customer where the manifest file was exactly 16KB — one kilobyte over IW10. Every single cold fetch required an extra RTT. Changing nothing but the manifest padding to fit under IW10 cut their start-up delay by 45ms in South America.

That's why many performance-tuned CDNs now use IW32 or IW64 on the edge to origin and sometimes edge to client where the network supports it. IW32 lets you send ~46KB immediately, which covers the vast majority of HTML, JSON APIs, and small images in one RTT. The draft recommendations in the HTTP Working Group Specifications acknowledge this — larger initial windows reduce latency without meaningfully increasing congestion when paced correctly. But you can't just slam initcwnd 32 everywhere. On last-mile networks with shallow buffers, especially mobile, a large initial burst can cause self-inflicted loss that triggers exactly the RTO we want to avoid.

The right approach is pacing and per-route tuning. We don't set a global IW. We set it per /24 based on historic loss and RTT, and we use FQ/pacing to spread that initial burst over ~0.5 RTT. Without pacing, IW32 dumps 32 packets back-to-back, which will overflow a lot of home routers. With pacing, you get the latency benefit without the burst loss. If you're trying to reason about the right window, you have to do the math against the Bandwidth-Delay Product and CDN Tuning: Filling the Pipe Without Overwhelming It — if your BDP for a 50ms RTT at 20Mbps is ~125KB, IW10 leaves you using only 12% of the pipe on the first RTT. You'll spend three more RTTs just catching up.

# Check and tune initial congestion window (requires iproute2)
ip route show | grep initcwnd
# 10.0.0.0/24 via 192.0.2.1 dev eth0 initcwnd 10

# Set a per-route IW for high-BDP PoP to PoP paths
sudo ip route change 203.0.113.0/24 via 192.0.2.1 dev eth0 initcwnd 32 initrwnd 32

# Verify with ss and confirm pacing is enabled
sysctl net.ipv4.tcp_pacing_ca_ratio
# net.ipv4.tcp_pacing_ca_ratio = 120  # pacing enabled for cubic/bbr

# Calculation for required IW to send 100KB in 1 RTT:
# IW_required = object_size / MSS = 102400 / 1460 = ~70 segments
# At 42ms RTT, IW10 needs 3 RTTs (14KB -> 29KB -> 58KB), IW32 needs 2 RTTs (46KB -> 93KB)

What First Bytes Actually Cost in Milliseconds

Let's put real numbers on connection warmup because averages hide it. On our US-East edge, median RTT to eyeball networks is about 32ms, p95 is 85ms, and p99 over mobile is north of 140ms. When we A/B tested IW10 vs IW32 for cache-miss HTML (avg 68KB), the median TTFB improvement was 28ms. The p95 improvement was 81ms. That's not because the bytes got smaller — it's because we eliminated an entire round trip for the tail. The web.dev Articles on optimizing LCP keep pointing to this: your largest contentful paint is often blocked by that first 50-100KB, exactly the range where slow start hurts most.

There's a direct link to window scaling too, and people conflate the two. Window scaling lets you have a large receive window once you're past slow start; IW determines how fast you get there. I've audited configs where window scaling was correctly enabled but the team never touched IW, so they had a 2MB receive window that took four RTTs to actually fill. If you haven't read it, our piece on TCP Window Scaling and Its Impact on CDN Throughput: Why Defaults Fall Short explains why you need both knobs. One gives you the ceiling, the other gets you off the floor.

The table below is from a controlled test we ran last quarter: single 68KB HTML document, TLS 1.3 1-RTT, cold TCP, no packet loss, BBR congestion control, client RTT emulated at 40ms, 80ms, and 160ms. Times are measured from TCP SYN to last byte ACKed at the sender. It shows exactly why first bytes are the slowest and how much IW tuning helps before you even consider 0-RTT or connection reuse.

RTT Emulated IW10 - Time to 68KB IW32 - Time to 68KB RTTs Required (IW10 / IW32)
40ms (US metro) 132ms (SYN + 2 data RTTs) 88ms (SYN + 1 data RTT) 3 / 2
80ms (Transatlantic) 254ms 172ms 3 / 2
160ms (Intercontinental) 498ms 336ms 3 / 2
40ms with 2% loss 412ms (RTO on first window) 298ms (fast recovery triggered) 3+ / 2+
40ms, IW10 + BBR pacing 128ms, 0.4% loss 84ms, 0.6% loss 3 / 2

Tuning Your Initial Window: From Theory to Edge Configuration

If you're still running the Linux default IW10, you're deliberately throttling every cold connection. With a standard 1460-byte MSS, IW10 gives you just ~14.6KB in that first round trip before you have to wait for an ACK. For a typical 85KB hero image or 120KB blocking JavaScript bundle, that means you're forcing 3-4 round trips in slow start alone. On a 70ms transatlantic link, that's 210-280ms of pure congestion window growth before you've even hit steady state. Bump that to IW32 (~46.7KB) and you've just cut that initial sequence to 2 RTTs, shaving 70-140ms off your Time to First Byte without changing a single byte of your application code.

We rolled this out across our European PoPs last year and the impact was immediate but nuanced. You can't just flip a sysctl and walk away. Your CDN Percentile Latency Analysis: What p50, p95, and p99 Actually Tell You will tell the real story. Our p50 for small object TTFB dropped by 18ms on average, but our p95 barely moved — because those tail users weren't actually window-limited, they were loss-limited. If you've got clients on congested mobile networks, a larger IW just means you dump more packets into an already full buffer. You’ve got to pair the increase with proper pacing. Switching your qdisc to `fq` and enabling BBR handles this beautifully; it paces that larger initial burst instead of sending a 32-packet spike line-rate.

Here's the practical stack we use on Ubuntu 22.04 / kernel 5.15+ edge nodes. Don't set this globally via `tcp_init_cwnd` if you can avoid it — set it per-route so you can A/B test by PoP and roll back safely. And always bump `initrwnd` alongside `initcwnd`, otherwise you're just moving the bottleneck to the client's receive window.

# 1. Verify current MSS and initial window (10 = ~14.6KB default)
ss -i dst 1.1.1.1 | grep cwnd
ip route show | grep initcwnd

# 2. Set per-route IW32 (32 * 1460 = 46720 bytes) for the edge interface
# Use initcwnd 32 and initrwnd 32 to keep both sides balanced
sudo ip route change default via 10.0.0.1 dev eth0 initcwnd 32 initrwnd 32

# 3. Enable BBR + FQ pacing to avoid IW32-induced bursts
sudo sysctl -w net.core.default_qdisc=fq
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
sudo sysctl -w net.ipv4.tcp_notsent_lowat=16384

# 4. Persist in /etc/sysctl.conf and verify with tcp_info
# Check: nstat | grep TcpExtTCPSlowStartRetrans for retrans cost

Beyond IW Tuning: Diagnosing Bufferbloat, Jitter, and Reordering at Scale

Raising IW is the easy win. The hard part is figuring out why some connections still crawl after you've done it. Nine times out of ten, it's not slow start anymore — it's what slow start *triggers*. We've seen IW32 expose latent bufferbloat on last-mile CPEs that IW10 was quietly masking. The client ACKs get queued behind a bloated upstream buffer, your smoothed RTT spikes from 45ms to 320ms, and CUBIC panics and halves its window. If your edge monitoring shows sawtooth RTT graphs that correlate with throughput bursts, you're not window-limited, you're buffer-limited. That's where CDN Jitter Measurement and Its Impact on User Experience becomes more important than raw bandwidth — a steady 30ms jitter will hurt rebuffer rates more than a 20% packet loss ever will.

The other silent killer is reordering, especially on anycast and multi-path transit. Modern CDNs love ECMP, but if your per-packet hashing is even slightly uneven, you'll get 2-3% out-of-order delivery. TCP sees that as loss. With IW10, you might not notice because you're sending so little. With IW32, those 32 segments hit three different upstream paths and your client fires off duplicate ACKs before the first RTT completes. You end up in fast recovery before you've even left slow start. We've tracked this via `tcp_info` reordering counters and qlog traces — if your `TCPSackShifted` and `TCPLoss` counters jump immediately after an IW increase, it’s not congestion, it’s Packet Reordering Effects on CDN Performance: The Hidden Throughput Killer. Fix it with flow-aware pacing on the edge or by pinning flows more deterministically, not by shrinking the window again.

So how do you troubleshoot it live? Don't rely on synthetic curl tests. Pull real `getsockopt(TCP_INFO)` samples from nginx or Varnish. Look specifically at `tcpi_rtt`, `tcpi_rttvar`, `tcpi_snd_cwnd`, and `tcpi_total_retrans`. If `rttvar` is > 40% of `rtt` on IW32 connections but not on IW10, you've got burst-induced jitter. If `snd_cwnd` stalls at 32 and never grows, you're hitting an initcwnd ceiling on the return path. We log these to ClickHouse every 10 seconds and alert when p95 `rttvar` crosses 25ms for more than 5 minutes — that's our early warning that we need to tune pacing, not capacity.

Frequently Asked Questions

Won't increasing IW to 32 cause more packet loss and retransmits?

It can, if you blast 32 segments at line-rate into a shallow buffer. That's why you never raise IW without pacing. With `fq` + BBR, that 46KB initial burst is paced over the first few milliseconds of the RTT rather than sent as a single burst. In our production tests, moving from IW10 to IW32 with pacing increased retransmits by only 0.12% while cutting median object completion time by 22%. Without pacing, that same change drove retransmits up 1.8% and actually hurt p95 latency.

Should I just enable TCP Fast Open and skip slow start tuning?

TCP Fast Open (TFO) and IW tuning solve different problems. TFO saves one full RTT on the handshake by sending data in the SYN, which is fantastic — roughly 70-90ms saved on high-RTT links. But you still enter slow start immediately after. Think of TFO as removing the handshake tax, while a larger IW reduces the slow start tax. For best results, deploy both. We've seen TFO + IW32 together cut cold-start 100KB transfers from 4 RTTs to just 1.5 RTTs.

What's the difference between CUBIC and BBR for slow start performance?

CUBIC's slow start is traditional exponential growth and it's aggressive — it doubles cwnd every RTT until it sees loss. BBR doesn't use exponential slow start at all; it estimates the bottleneck bandwidth and paces accordingly. On clean, high-bandwidth paths CUBIC will ramp slightly faster, but on variable mobile or bufferbloated paths BBR is far more stable. Our rule of thumb: use BBR on edge nodes serving mobile/end users, keep CUBIC for cache-fill and origin-facing interconnects where buffers are deep and predictable.

How do I know if my CDN provider is actually using an optimized IW?

Run a simple packet capture test. From a cold VM, `curl` a small uncached 50KB object from your CDN and capture with `tcpdump` or `wireshark`. Count data bytes sent by the server before the first client ACK arrives — that's your effective IW. If it's ~14KB, you're on IW10. If it's ~29KB, you're on IW20. Cloudflare and Akamai typically run IW32 or higher on most PoPs now; many smaller providers still ship defaults. If you see IW10, open a ticket — it's the single cheapest performance win they can give you.

Related Articles

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