Networking / transport / tcp / congestion control
Slow start is not slow, and your keep-alive connection is not warm
That slow start is a slow warm-up phase you can wait out, that it ends early in any real transfer, and that a long-lived keep-alive connection stays warm. Growth is exponential, so slow start is the fastest thing TCP does; but its cost is counted in round trips rather than in seconds, which means a 1 MB response costs the same number of round trips on a 10 Mbps link and a 10 Gbps one, and for anything under a few megabytes slow start is not a phase of the transfer, it is the whole transfer. Linux then leaves slow start well below the bandwidth-delay product because CUBIC's HyStart exits on a delay rise once cwnd reaches 16, and net.ipv4.tcp_slow_start_after_idle defaults to 1, which halves the window once per retransmission timeout of idleness down to the initial 10 segments — so a connection used once a second is cold on every request.
Slow start is the fastest thing TCP does. It doubles the amount of data in flight every round trip, which is the steepest growth anywhere in the protocol. The name describes where it starts, not how it moves — and for any response smaller than a few megabytes it is not a warm-up phase before the transfer, it is the entire transfer.
A sender may have at most one congestion window of unacknowledged data
outstanding. That window — cwnd, counted in segments of one
maximum segment size (MSS) each — starts at 10 segments, which
RFC 6928 defines as IW = min(10*MSS, max(2*MSS, 14600)) and
Linux hard-codes as TCP_INIT_CWND 10. At the 1448-byte MSS a
Linux connection with timestamps usually negotiates, that is 14,480 bytes:
the origin of the "keep it under 14 KB" rule for a first page load. Every
acknowledgement then adds its own size back to the window
— RFC 5681 writes it as cwnd += min(N, SMSS) — so a whole
window of acknowledgements doubles the window.
Doubling means the number of round trips to deliver a response is a logarithm of its size, and the wall-clock cost is that count multiplied by the round-trip time. Neither term contains the link rate. That is the entire lesson, and the panel below is built to make it uncomfortable.
It is a packet model of one response on one connection. The sender emits while its window has room and its pacer allows, a bottleneck link serves at exactly its rate and drops what will not fit, acknowledgements return one propagation delay after delivery, and the response is finished when its last byte reaches the receiver. Start by dragging link rate across its whole range and watching the hero number.
One response, 1448-byte segments, CUBIC after slow start, paced at
Linux's tcp_pacing_ss_ratio of 200%. The bottleneck buffer
holds two bandwidth-delay products or 32
packets, whichever is larger. Time zero is the sender's first response
byte; the clock stops when the last byte reaches the receiver.
Solid is cwnd, dashed is data actually in flight. The teal
line is the bandwidth-delay product — the window that exactly fills the
path. The magenta line is the whole response in segments: when it sits
below the teal line, the transfer ends before the pipe is ever full.
Each step is one round trip's worth of window. The staircase is the shape of the whole problem: the response arrives in discrete round-trip instalments, and the link rate only sets how steep each riser is.
A deliberately small model. One flow, one bottleneck, no competing
traffic, no delayed acknowledgements, no selective acknowledgement
accounting, and propagation split evenly between the two directions. What
it does implement follows its specification: the RFC 5681 slow-start
increment, the RFC 6928 initial window, CUBIC's post-slow-start growth
from RFC 9438, HyStart's delay and ack-train rules with the constants in
net/ipv4/tcp_cubic.c, the RFC 8985 loss probe timeout of
2 × smoothed RTT, Linux's 1-second initial retransmission timeout
and 200 ms floor, and the halve-per-timeout idle decay in
tcp_cwnd_restart. HyStart's ack-train test here uses the
threshold a paced socket gets — delay_min plus a
cushion of up to 1 ms, which is what hystart_ack_delay()
returns, and which is the case that applies because Linux paces slow
start. An unpaced socket would halve that threshold instead. Treat the
shapes as real and the absolute milliseconds as illustrative.
Three things to do, in order. One: leave everything as it loads and drag link rate from 1 Mbps to 10 Gbps. From 100 Mbps onward the hero number stops moving: a hundredfold more link buys nothing, because the transfer is not waiting for the link. Two: put the link rate back to 100 Mbps and drag round-trip time instead. That number moves the response time almost proportionally, because round trips are the unit the transfer is priced in. Three: put the round-trip time back to 80 ms, set the response size to 64 KB, and drag initial window from 10 segments to 46. The hero number goes from 226 ms to 79 ms, and you did it without touching bandwidth, latency, or anything the application can see.
The unit is round trips, and there are only ever a handful of them
Work the arithmetic once and you never need the panel again. Starting at
10 segments and doubling, the windows are 10, 20, 40, 80, 160, 320, 640 —
and the cumulative total after n round trips is
10 × (2n − 1)
segments. Invert it and the number of round trips a response of
S segments needs is ceil(log2(S/10 + 1)). For a
1 MB response that is 725 segments and 7 round trips. For 64 MB it is
46,346 segments and 13 round trips. Sixty-four times the data costs six
more round trips, because that is what a logarithm does: six doublings.
Multiply by the round-trip time and you have the floor on the response time before anything else is considered. Put the panel back where it loaded — response 1 MB, initial window 10, the last section left both moved — and read it. The default path is 80 ms, so 7 round trips is 560 ms of pure waiting; it measures 531 ms because the last round is partial and the clock stops when the last byte lands rather than when it is acknowledged. Of that 531 ms, 88 ms is the link transmitting. The other 443 ms is a completely idle link and a sender that is not allowed to use it.
Walk the response size slider with everything else at its default and read the of link rate used column as you go: 14 KB takes 79 ms and uses 1.5% of the link; 64 KB takes 226 ms and uses 2.3%; 256 KB takes 374 ms and 5.6%; 1 MB takes 531 ms and 16%; 4 MB takes 797 ms and 42%; 16 MB takes 1.85 s and 73%; 64 MB takes 6.06 s and 89%. That last figure is where the intuition "slow start does not matter" comes from, and it is correct — for 64 MB. Applying it to a 40 KB API response is a factor of forty error.
The extreme case is worth setting up deliberately, because it is the most
common transfer on the internet. Put the response at 14 KB and leave the
rest alone. The response time is 79 ms, one round trip, and the
worst round-trip time readout says none seen: the entire
response was delivered before a single acknowledgement made it back to the
sender. The sender never learned the path's round-trip time, never grew its
window, never had an opportunity to adapt to anything. Fourteen kilobytes
is not a magic number — it is
IW = min(10*MSS, max(2*MSS, 14600)) from RFC 6928 evaluated at
a normal MSS, which is to say it is the largest response that costs exactly
one round trip.
Now the control that actually moves the number. Set the response back to
64 KB and walk the initial window: 1 segment gives 470 ms, 2 gives
392, 3 gives 361, 4 gives 315, the RFC 6928 default of 10 gives 226,
20 gives 155, 30 gives 137, and 46 gives 79 ms — one round trip, the
same as a 14 KB response. The 3-segment step is what a stack from before
2013 would do on this path. RFC 3390 §1 set the upper bound at
min (4*MSS, max (2*MSS, 4380 bytes)); RFC 5681 §3.1
dropped the formula and states the bands directly — "If (SMSS > 1095
bytes) and (SMSS <= 2190 bytes): IW = 3 * SMSS bytes and MUST NOT be
more than 3 segments" — and a 1448-byte MSS sits in that band, so three
segments is what it permits. The 46 is what a content delivery network
with a tuned initcwnd looks like. Nothing about the link
changed between those two numbers. A 64 KB response went from 361 ms to
79 ms because somebody edited a routing table.
Linux does not run slow start to completion
Doubling has an obvious failure mode: the last doubling before the pipe is full takes the window to twice what the pipe holds, and every packet of that excess goes into the bottleneck buffer. Slow start is the single most aggressive burst a TCP connection ever produces, and it is produced before the sender knows anything about the path.
HyStart is Linux's answer, and it has been on by default in CUBIC for
years: net/ipv4/tcp_cubic.c declares
static int hystart = 1 and
hystart_detect = HYSTART_ACK_TRAIN | HYSTART_DELAY. It runs
two tests, both only once cwnd has reached
hystart_low_window, which is 16 segments.
- Delay increase. Each round has a minimum round-trip time. Once
eight samples are in, if that round minimum exceeds the connection's
all-time minimum by more than
clamp(delay_min/8, 4 ms, 16 ms), a queue is forming, andssthreshis set to the currentcwnd. On the panel's 80 ms path that threshold is 10 ms. - Ack train. While consecutive acknowledgements arrive within
hystart_ack_delta_usof each other — 2000 microseconds — the sender measures how long the round's acknowledgement stream has been running. If that exceedsdelay_minplus a pacing cushion of at most 1 ms, the window is already spread across the entire pipe, so the pipe is full. An unpaced socket halves that threshold (if (sk->sk_pacing_status == SK_PACING_NONE) threshold >>= 1); Linux paces slow start, so the full threshold is the one that normally applies.
Put the initial window back to 10 — the last section left it at 46 — set the response to 16 MB, and untick HyStart. Watch what changes and what does not. The response time is 1.85 s either way. What changes is the peak window: 1,970 segments with HyStart, 2,626 without, against a bandwidth-delay product of 661, and the packets dropped readout, which goes from 0 to 666. HyStart cut the overshoot by a third, destroyed 666 fewer packets, and cost nothing in elapsed time. The decision log tells you which test fired: with HyStart the ack train ran for 82 ms against an 80 ms minimum, so slow start ended at cwnd 1,965; without it, slow start ends the old way, by losing a packet, at cwnd 1,844.
Two things HyStart does not do, and the panel is honest about both. It does not make the transfer faster — the ramp was never the bottleneck at that size. And it does not keep the queue empty: the worst round-trip time readout says 238 ms with HyStart and 239 ms without, because after slow start ends CUBIC keeps probing upward in congestion avoidance and CUBIC has no delay signal at all. Preventing the standing queue is a job for the queue, not the sender — which is the whole of bufferbloat.
HyStart's own weakness is that a delay spike caused by anything other than
your own queue — a Wi-Fi retransmission, a scheduling hiccup, a cellular
handover — reads as congestion and ends slow start early, for good. That is
why RFC 9406 specifies HyStart++, which drops the ack-train test entirely
— §4.1 says "the Inter-Packet Arrival algorithm does not perform well
and is not able to detect congestion early, primarily due to ACK
compression" — keeps the delay-increase test, and then spends
CSS_ROUNDS of 5 round trips in a "Conservative Slow Start"
phase growing more gently rather than exiting outright, so that if the
round-trip time comes back down, slow start resumes. Section 1 puts the
reason in one sentence: "This mitigation improves performance in the
presence of jitter." QUIC stacks have gone the same way: Mozilla's neqo,
the QUIC implementation Firefox ships, merged
"make slow start pluggable and add hystart++" in March 2026.
Your keep-alive connection is not warm
Everything above assumes the window survives. On Linux, by default, it does
not. net.ipv4.tcp_slow_start_after_idle initialises to 1 —
tcp_ipv4.c says so in a comment,
"By default, RFC2861 behavior" — and what that behaviour does
is in tcp_cwnd_restart, under a comment reading
RFC2861. Reset CWND after idle period longer RTO to "restart
window". For every retransmission timeout's worth of idle time,
halve cwnd, with a floor of the initial window. The body of
it, verbatim from net/ipv4/tcp_output.c:
WRITE_ONCE(tp->snd_ssthresh, tcp_current_ssthresh(sk));restart_cwnd = min(restart_cwnd, cwnd);while ((delta -= inet_csk(sk)->icsk_rto) > 0 && cwnd > restart_cwnd)
cwnd >>= 1;tcp_snd_cwnd_set(tp, max(cwnd, restart_cwnd));
The first line is the one people miss. ssthresh is
preserved across the idle period. If the first request ended slow
start somewhere, the second one doubles from 10 back up to that same
threshold and then switches to congestion avoidance rather than
overshooting the path a second time. The window is reset; the estimate of
where the ceiling is, is not.
Re-tick HyStart, put the response back to 1 MB, then set
idle, then a second request and watch the second readout. The
first request takes 531 ms every time. The second one takes 134 ms after a
200 ms gap, 233 ms after 500 ms, 379 ms after one second, and 532 ms
after two seconds — which is to say, after two seconds of silence the
connection performs exactly as if it had just been opened. The 200 ms gap
is free, and the decision log says why: delta -= icsk_rto has
to come out strictly positive, and 200 minus 200 is zero, so the loop runs
zero times. Gaps beyond two seconds change nothing more, because the window
has already hit its floor of 10 segments. Untick the sysctl and the second
request is 134 ms at any gap you choose, a four-fold difference produced by
one boolean.
The retransmission timeout is what sets the decay rate, and on a
low-latency path it is pinned at Linux's TCP_RTO_MIN of
200 ms. So a connection carrying one request per second — a health check, a
metrics push, a database driver on a quiet pool, a gRPC channel between two
services — is at the initial window for every single request, forever. It
is not a cold-start problem that goes away after warm-up. There is no
warm-up.
This is not a theoretical complaint. The Zcash node zebra
added a startup warning specifically for it, because its protocol issues
one request per block and goes quiet in between, so the kernel reset the
window before every request. The connection pool was doing its job
perfectly; the thing being pooled had been reset underneath it.
RFC 2861 has a defensible reason for this. A sender that has been idle no longer has a valid estimate of the path — the congestion window is a measurement, and measurements go stale. The counter-argument, which is why a great many production systems set the sysctl to 0, is that a two-second-old estimate of a path that has not changed is far better than no estimate at all, and that the cost of being wrong is one round of loss while the cost of being conservative is paid on every request forever. Turn it off when your traffic is bursty over long-lived connections and your paths are stable; leave it on when your connections are long-lived and cross paths whose capacity genuinely changes, such as anything mobile.
One packet lost in the last window costs a second
Fast retransmit needs three duplicate acknowledgements. A duplicate acknowledgement is produced by a packet arriving after a hole, so it takes three packets behind the lost one to trigger it. Packets already in flight ahead of the loss produce nothing. Therefore a packet lost in the last window of a response cannot be detected by duplicate acknowledgements at all — there is nothing behind it — and the sender falls back to a timer.
Put idle, then a second request back to none and re-tick tcp_slow_start_after_idle — the last section left both moved — then set the response to 14 KB, the round-trip time to 20 ms, and the loss to one packet, last window. With Tail Loss Probe on the response takes 61 ms against 20 ms clean. Untick it: 1.03 seconds. One packet out of ten, on a path whose round trip is 20 milliseconds, costs a full second — because RFC 6298 §2.1 says that "until a round-trip time (RTT) measurement has been made for a segment sent between the sender and receiver, the sender SHOULD set RTO <- 1 second", and this connection has not taken a single round-trip sample yet, so one second is the only estimate it has.
RFC 8985 defines the fix. A tail loss probe is armed at
PTO = 2 × SRTT, never later than the retransmission
timeout would have fired, and it retransmits the last segment. For a tail
loss that probe is the repair — the receiver gets the missing
segment directly — and the connection stays in fast recovery with
cwnd multiplied by 0.7 rather than dropping to 1 and starting
slow start over. Leave the round-trip time at 20 ms and move the response
to 64 KB: the same experiment reads 97 ms with the probe against 266 ms
without, a factor of 2.7.
Now find the boundary, because there is one. Leave the response at 64 KB
and move the round-trip time to 80 ms: 387 ms with the probe against
465 ms without. The probe still helps, but only by 17% instead of by a
factor of 2.7, because 2 × SRTT is 160 ms on that path
while the retransmission timeout floor is only 200 ms — the two timers have
almost converged. The probe is worth most exactly where the timeout is
worth least: on short paths, where Linux's TCP_RTO_MIN of
HZ / 5, 200 ms, is ten round trips of dead air. Note that
200 ms is itself a deviation: RFC 6298 §2.4 says "if it is less than
1 second, then the RTO SHOULD be rounded up to 1 second", and every
mainstream stack ignores that.
Switch the loss to one packet, 40% through and the probe becomes irrelevant. Put the round-trip time back to 20 ms and walk the response size: 36 ms at 14 KB, 61 ms at 64 KB, 160 ms at 1 MB — each one identical whether the probe is ticked or not. There are packets behind the hole, duplicate acknowledgements arrive, fast retransmit does its job in one round trip. The entire tail-loss problem is a property of where in the response the packet was lost, and short responses are almost all tail.
Where raising the initial window stops helping
The panel makes raising initcwnd look free, and at the scale of
one connection it nearly is. Three things bound it.
First, the receiver. The window that governs transmission is the minimum of
the congestion window and the receiver's advertised window, so an
initcwnd of 46 against a receiver advertising 64 KB delivers
45 segments and no more. Raising initcwnd without also raising
initrwnd on the return path — and without
window scaling negotiated at all — buys nothing.
Second, the bottleneck buffer. An initial window of 46 segments is a
66 KB burst sent at line rate into a path the sender has never measured.
Set the loss back to none, the round-trip time back to 80 ms, the
response to 64 KB, and the link rate to 1 Mbps, then read the
packets dropped readout as you move the initial window between 10
and 46. At 10 it is 0. At 46 it is 11 — the burst is 46
packets, the bottleneck buffer on that path holds 32, and the excess is
destroyed before the sender has any idea the path is slow. The response
time is 597 ms either way: on a link this slow the tuned
initcwnd bought exactly nothing and cost eleven
retransmissions. RFC 6928 is explicit about who pays. Section 8 says
"much of the negative impact from an increase in the initial window is
likely to be felt by users behind slow links with limited buffers", and
its remedy is not on the sender at all: "the negative impact can be
mitigated by hosts directly connected to a low-speed link advertising an
initial receive window smaller than 10 segments."
Third, the thing everyone forgets: this is a shared resource. Every connection's initial window is sent before it has any information, so on a congested bottleneck it is pure open-loop traffic. One tuned server is invisible; the entire web tuned to 46 is a different network. That is the argument that has been running on and off since the 2010 measurements that produced IW10, and it is still running.
Which leaves the honest advice. If your responses are small and your
round-trip times are large, the ranked list is: make the response fit in the
initial window, move the content closer to the client, reuse connections and
stop them going idle, then raise initcwnd. Buying bandwidth is
not on the list at all: put the response back to 1 MB and the initial
window back to 10, and the hero number is 531 ms at 100 Mbps, 531 ms at
1 Gbps and 531 ms at 10 Gbps.
Checking it on a real system
Every number in the panel has a counterpart you can read off a live system.
ss -tiduring a transfer.cwnd:is the window in segments andssthresh:tells you whether slow start has ended — ifssthreshis absent from the output, the socket is still in slow start. The suppression isss's, not the kernel's: the kernel exportstcpi_snd_ssthreshasTCP_INFINITE_SSTHRESH,0x7fffffff, until something lowers it, and iproute2'sss.conly records the field at all underif (info->tcpi_snd_ssthresh < 0xFFFF).bytes_ackeddivided bymssagainstcwndtells you how many doublings you actually got.nstat -az | grep -i hystart, or the same four counters in/proc/net/netstat:TcpExtTCPHystartTrainDetect,TcpExtTCPHystartTrainCwnd,TcpExtTCPHystartDelayDetectandTcpExtTCPHystartDelayCwnd. The two Detect counters are how many connections left slow start by each test; the two Cwnd counters are the summed windows at which they left. Divide and you get the average window HyStart is exiting at on your machine. If that is far below your paths' bandwidth-delay product, HyStart is firing early on you.ip route showandip route change default via GW dev eth0 initcwnd 30 initrwnd 30. This is per-route, it survives nothing across a reboot unless you persist it, andinitrwndmatters as much asinitcwndfor anything where the client sends.sysctl net.ipv4.tcp_slow_start_after_idle. Check it before you spend a day profiling an application whose first-request latency is the same as its cold latency.curl -w '%{time_connect} %{time_starttransfer} %{time_total}\n'against the real endpoint, run twice in a row on one connection with--keepalive, then again with a sleep in between. The difference between the second and third numbers is the sysctl above, measured on your own traffic.
The confirming test is the same one that settles most transport arguments: run the transfer at two link rates and see whether the time changes. If a tenfold link buys you nothing, you are not bandwidth-limited and no amount of capacity will help. Then run it at two round-trip times. If the time tracks the round trip almost exactly, the transfer is priced in round trips and the only levers are the ones that change how many there are.
A JSON API returns 48 KB. The client and server are in different regions,
round-trip time 90 ms, over a 10 Gbps backbone. p50 response time
measured at the client is 274 ms and the server's own handler time is
4 ms. The team proposes moving to a 40 Gbps link. ss -ti on
the server socket shows cwnd:44 and no ssthresh
field. What is actually happening?
Next: the ceiling the doubling runs into, the bandwidth-delay product; the option without which no window above 64 KB is reachable at all, window scaling; the queue the last doubling fills, bufferbloat; the sender that estimates the pipe instead of doubling into it, BBR; and the other reason a small response sits still on an idle link, Nagle and delayed ACK.