Slow start grows a TCP sender’s congestion window while it learns how much outstanding data the path can support. With enough data and suitable acknowledgments, the classic behavior is approximately exponential growth across round trips. “Doubles every RTT” is a useful simplified pattern, not an unconditional timer rule. The window changes in response to transport feedback and the implementation’s growth conditions.
When it breaks
Waiting after connect does not automatically warm up the congestion window. If the application sends nothing, there are no data acknowledgments to support that growth. A very small exchange may also finish without using the existing flight budget. Expecting every short request to display a large doubling sequence mistakes an application-limited transfer for a continuously backlogged sender.
The initial window is not universally one or two segments on modern systems. RFC 6928 describes an experimental extension allowing a larger initial window, and the tested Linux sender begins with ten segments. The relevant unit matters too: Linux TCP_INFO reports snd_cwnd in segments, whereas many protocol explanations express congestion windows in bytes. Compare it with the sender’s MSS before reasoning about outstanding data volume.
Slow start does not always continue until a first loss, followed by a universal collapse. The slow-start threshold helps select between startup growth and congestion avoidance, and implementations may use additional startup detection. Recovery from a retransmission timeout differs from fast recovery. Applying one cartoon curve to every algorithm, loss signal, and workload can produce an incorrect diagnosis from otherwise valid measurements.
Check it quickly
Save this as check-slow-start.py and run python3 check-slow-start.py with Docker. The disposable environment uses Python 3.11.16 and iproute2 6.1.0, tested on Linux 6.12.76. NET_ADMIN and SYS_ADMIN support nested namespaces. A new veth joins client and target; netem adds ten milliseconds of egress delay in each direction, with all changes confined to those virtual endpoints:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,os,select,signal,subprocess,time
def run(*args,check=True,input=None,timeout=8):
result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout)
if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
return result
def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs)
def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs)
def stop(process):
if process.poll() is None:
process.terminate()
try:process.wait(timeout=2)
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2)
def line(process,timeout=3):
assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
value=process.stdout.readline();assert value,('child exited without a response',process.stderr.read())
return value
def spawn(stack,command):
process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
stack.callback(stop,process);return process
@contextmanager
def lab(names):
with TemporaryDirectory(prefix='net-path-') as directory,ExitStack() as stack:
root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc))
stack.callback(run,'umount',str(proc))
for name in names:
run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False)
ip(name,'link','set','lo','up')
def setting(name,key,value):
assert name in names
path=proc/'sys'/key.replace('.','/')
return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip()
yield root,stack,setting
def wire(left,left_if,right,right_if,left_addresses,right_addresses):
run('ip','link','add',left_if,'type','veth','peer','name',right_if)
for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]:
run('ip','link','set',interface,'netns',name)
for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else []))
ip(name,'link','set',interface,'up')
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={}
for entry in data['nftables']:
rule=entry.get('rule',{})
if 'comment' in rule:
for expression in rule['expr']:
if 'counter' in expression:found[rule['comment']]=expression['counter']['packets']
return found
info_code=r'''
import struct
def tcp_info(stream):
raw=stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_INFO,104);assert len(raw)==104
offsets={'snd_mss':16,'unacked':24,'rtt_us':68,'snd_ssthresh':76,'snd_cwnd':80,'total_retrans':100}
return {'ca_state':raw[1],**{key:struct.unpack_from('=I',raw,offset)[0] for key,offset in offsets.items()}}
'''
with lab(['client','target']) as (root,stack,setting):
wire('client','c0','target','t0',['192.0.2.1/30'],['192.0.2.2/30'])
for name,interface in [('client','c0'),('target','t0')]:
ns(name,'tc','qdisc','add','dev',interface,'root','netem','delay','10ms','limit','10000')
server_code=r'''
import socket,json,hashlib,sys
size=int(sys.argv[1]);listener=socket.socket();listener.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
listener.bind(('192.0.2.2',18080));listener.listen();print('listening',flush=True)
with listener.accept()[0] as connection:
connection.settimeout(6);remaining=size;digest=hashlib.sha256()
while remaining:
chunk=connection.recv(min(65536,remaining));assert chunk;remaining-=len(chunk);digest.update(chunk)
assert digest.hexdigest()==hashlib.sha256(b'x'*size).hexdigest()
connection.sendall((digest.hexdigest()+'\n').encode())
'''
client_code=info_code+r'''
import socket,json,time,threading,sys,hashlib
size=int(sys.argv[1]);stream=socket.socket();stream.settimeout(6)
stream.setsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,b'reno');stream.connect(('192.0.2.2',18080))
initial=tcp_info(stream);time.sleep(.06);idle=tcp_info(stream);assert idle['snd_cwnd']==initial['snd_cwnd']
done=threading.Event();errors=[];samples=[initial,idle];reply=[]
def transfer():
try:
stream.sendall(b'x'*size)
with stream.makefile('rb') as incoming:reply.append(incoming.readline().decode().strip())
except BaseException as error:errors.append(repr(error))
finally:done.set()
worker=threading.Thread(target=transfer);worker.start();deadline=time.monotonic()+7
while not done.wait(.005):
assert time.monotonic()<deadline,'transfer deadline exceeded'
snapshot=tcp_info(stream)
if snapshot!=samples[-1]:samples.append(snapshot)
worker.join(timeout=1);assert not worker.is_alive() and not errors,errors
samples.append(tcp_info(stream));assert reply==[hashlib.sha256(b'x'*size).hexdigest()]
assert all(row['total_retrans']==0 for row in samples),samples
if size>1:assert max(row['snd_cwnd'] for row in samples)>initial['snd_cwnd']*2,samples
else:assert max(row['snd_cwnd'] for row in samples)==initial['snd_cwnd'],samples
print(json.dumps({'bytes':size,'algorithm':stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,16).split(b'\0')[0].decode(),
'before_data':initial,'after_idle_without_data':idle,'samples':samples,'sha256':reply[0]}));stream.close()
'''
results={}
for label,size in [('short',1),('bulk',1048576)]:
service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code,str(size)]);assert line(service).strip()=='listening'
results[label]=json.loads(ns('client','python3','-c',client_code,str(size),timeout=10).stdout)
service.wait(timeout=2);assert service.returncode==0
print(json.dumps({'egress_delay_each_direction_ms':10,'transfers':results}))
"""
name='xbcatm-tcpflow-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'Dockerfile').write_text(recipe)
build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN',
'--memory','256m','--pids-limit','128',name,'python3','-',input=program)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
docker('rm','-f',name);docker('image','rm',name)
The client explicitly selects Reno so the experiment is not silently comparing different default congestion controllers. It records TCP_INFO before sending, waits sixty milliseconds without data, and records it again. The window stays at ten segments in both observations. That directly tests the mistaken expectation that simply leaving a new connection open makes its congestion budget grow on a clock.
The first transfer sends one byte and receives a digest from the server. Samples include one segment outstanding and then none, while the congestion window remains ten. Successful delivery therefore does not require visible exponential growth for this workload. There was already enough permitted flight size for the entire request, so its limited application data does not exercise a bulk startup ramp.
The second transfer sends one mebibyte. A separate thread performs the blocking exchange while the main thread samples socket state, avoiding loss of observations while sendall waits. The captured progression includes ten, twenty, forty, and eighty segments, followed by less tidy values as acknowledgment grouping and transfer progress interact. The test requires substantial growth, not one exact sample sequence or one sample per RTT.
Both server and client verify the payload through SHA-256, and all sampled retransmission totals remain zero. The threshold is much larger than the observed window in this run. These observations support an ACK-driven startup demonstration without deliberately testing loss recovery or measuring a shared bottleneck. The artificial delay makes progress observable; it does not establish a real WAN’s capacity or fairness.
Interpret a growth trace in its workload context
Record algorithm, MSS, congestion window, threshold, outstanding data, RTT, and retransmission evidence together. A window larger than the current unacknowledged count is possible because the application or receiver can impose another limit. Treat a sampled window as the sender’s current allowance, not as a promise that exactly that many segments are continuously occupying the path.
Sampling also affects the picture. A userspace observer can miss intermediate changes, and its five-millisecond interval is not synchronized with ACK arrivals. Delayed acknowledgments, offload behavior, and scheduling can alter the visible progression. The fixture checks relationships supported by the data rather than declaring that an omitted intermediate value proves a broken congestion controller.
For short operations, reducing unnecessary new connections may avoid repeatedly starting with a fresh congestion budget, where the application protocol permits reuse. A reused connection can still restart with a reduced window after an idle period, depending on its restart policy. For bulk performance, test representative RTT, loss, receive behavior, and competing traffic before changing startup settings. This experiment separates idle time, a tiny successful exchange, and sustained data, providing a concrete basis for deciding whether slow start is relevant to the observed delay.
Sources
- RFC 5681 Congestion Control, ACK-driven slow start, congestion avoidance, threshold transitions and idle restart, checked 2026-09-12.
- RFC 6928 Initial Window, the experimental extension allowing a larger initial congestion window, checked 2026-09-12.
- Linux 6.12.76 TCP_INFO fields, congestion window, threshold, unacknowledged segments and retransmissions, checked 2026-09-12.