TCP’s header has a 16-bit receive-window field. Window scaling lets established connections interpret that field using a shift negotiated during setup, allowing a receiver to advertise more than 65535 bytes of space. The two directions can use different shifts. The extension enlarges the encoding of receive-window information; it does not directly enlarge every buffer or guarantee that a sender will keep that much data in flight.

When it breaks

A high-bandwidth path with substantial RTT may need much more outstanding data than an unscaled receive window can describe. For example, 100 megabits per second over an 80-millisecond round trip corresponds to roughly one million bytes in flight. A 65535-byte window would impose a much lower idealized rate. This bandwidth-delay calculation is a sizing estimate, not a throughput measurement or a guarantee that no other limit applies.

The sender also has a congestion window and needs application data to send. Enlarging receive-window encoding cannot fix a small congestion budget, an application that stops reading, packet loss, or a blocked route. A scaled zero window is still zero. Diagnose which resource is actually constraining progress instead of treating the presence of a window-scale option as proof that the connection should saturate the link.

Negotiation errors can make captures misleading. Both endpoints must offer the option for scaling to operate, and each scale applies to windows advertised by the endpoint that supplied it. The window field in SYN and SYN-ACK is never scaled. Applying the wrong direction’s shift, or multiplying the SYN window, produces an incorrect capacity calculation even when the packet bytes are perfectly valid.

Check it quickly

Save this as check-window-scaling.py and run python3 check-window-scaling.py with Docker. The disposable environment uses Python 3.11.16, iproute2 6.1.0, and tcpdump 4.99.3, tested on Linux 6.12.76. NET_ADMIN and SYS_ADMIN enable nested namespaces and their temporary proc mount. The endpoints use a new veth link, and settings affect only that laboratory:

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


def capture(stack,root,name,interface,expression,label):
    path=root/(label+'.pcap')
    process=subprocess.Popen(['ip','netns','exec',name,'tcpdump','--immediate-mode','-Z','root','-U','-n','-i',interface,'-w',str(path),expression],
        stdout=subprocess.DEVNULL,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process)
    deadline=time.monotonic()+3
    while time.monotonic()<deadline:
        assert process.poll() is None,'tcpdump exited before listening'
        if select.select([process.stderr],[],[],.1)[0] and 'listening on' in process.stderr.readline():return process,path
    raise AssertionError('tcpdump did not become ready')
def packets(process,path):
    import struct,socket
    process.send_signal(signal.SIGINT);_,diagnostic=process.communicate(timeout=3)
    assert process.returncode==0,diagnostic
    raw=path.read_bytes();assert raw[:4]==bytes.fromhex('d4c3b2a1') and struct.unpack_from('<I',raw,20)[0]==1
    offset=24;result=[]
    while offset<len(raw):
        seconds,micros,size,original=struct.unpack_from('<IIII',raw,offset);offset+=16
        frame=raw[offset:offset+size];offset+=size
        if frame[12:14]!=b'\x08\x00' or frame[23]!=6:continue
        ihl=(frame[14]&15)*4;length=struct.unpack_from('!H',frame,16)[0];tcp=14+ihl
        sport,dport,seq,ack=struct.unpack_from('!HHII',frame,tcp);header=(frame[tcp+12]>>4)*4
        opts={};cursor=tcp+20
        while cursor<tcp+header:
            kind=frame[cursor]
            if kind==0:break
            if kind==1:cursor+=1;continue
            option_length=frame[cursor+1];data=frame[cursor+2:cursor+option_length];assert option_length>=2
            if kind==2:opts['mss']=struct.unpack('!H',data)[0]
            elif kind==3:opts['wscale']=data[0]
            elif kind==4:opts['sack_ok']=True
            elif kind==5:opts['sack']=[list(pair) for pair in struct.iter_unpack('!II',data)]
            cursor+=option_length
        body=frame[tcp+header:14+length]
        result.append({'t':seconds+micros/1e6,'src':socket.inet_ntoa(frame[26:30]),'dst':socket.inet_ntoa(frame[30:34]),
            'sport':sport,'dport':dport,'seq':seq,'ack':ack,'flags':frame[tcp+13],'window':struct.unpack_from('!H',frame,tcp+14)[0],
            'length':len(body),'options':opts,'preview':body[:24].hex()})
    return result
with lab(['client','target']) as (root,stack,setting):
    wire('client','c0','target','t0',['192.0.2.1/30'],['192.0.2.2/30'])
    server_code=r'''
import socket,json
def read_exact(stream,length):
    data=b''
    while len(data)<length:
        chunk=stream.recv(length-len(data));assert chunk,'premature EOF';data+=chunk
    return data
listener=socket.socket();listener.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
listener.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,262144)
listener.bind(('192.0.2.2',18080));listener.listen();print('listening',flush=True)
with listener.accept()[0] as connection:
    connection.settimeout(4);print(json.dumps({'rcvbuf':connection.getsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF)}),flush=True)
    assert read_exact(connection,4)==b'ping';connection.sendall(b'pong')
    assert connection.recv(1)==b''
'''
    client_code=r'''
import socket,json,sys
def read_exact(stream,length):
    data=b''
    while len(data)<length:
        chunk=stream.recv(length-len(data));assert chunk,'premature EOF';data+=chunk
    return data
s=socket.socket();s.settimeout(4);s.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,65536)
s.bind(('192.0.2.1',40400+int(sys.argv[1])));s.connect(('192.0.2.2',18080));s.sendall(b'ping')
assert read_exact(s,4)==b'pong'
print(json.dumps({'rcvbuf':s.getsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF),'reply':'pong'}),flush=True)
assert sys.stdin.readline().strip()=='close';s.close()
'''
    outcomes={}
    for enabled in [1,0]:
        setting('client','net.ipv4.tcp_window_scaling',enabled);setting('target','net.ipv4.tcp_window_scaling',enabled)
        service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(service).strip()=='listening'
        tap,path=capture(stack,root,'client','c0','tcp port 18080','window'+str(enabled))
        client=spawn(stack,['ip','netns','exec','client','python3','-u','-c',client_code,str(enabled)])
        client_result=json.loads(line(client));server_result=json.loads(line(service))
        diagnostics=ns('client','ss','-tin','state','established').stdout
        client.stdin.write('close\n');client.stdin.flush();client.wait(timeout=2);service.wait(timeout=2)
        assert client.returncode==service.returncode==0
        observed=packets(tap,path);syn=next(p for p in observed if p['flags']&0x12==0x02)
        synack=next(p for p in observed if p['flags']&0x12==0x12)
        advertised=next(p for p in observed if p['sport']==18080 and p['length']==4)
        if enabled:
            assert 'wscale' in syn['options'] and 'wscale' in synack['options']
            effective=advertised['window']<<synack['options']['wscale'];assert effective>65535
        else:
            assert 'wscale' not in syn['options'] and 'wscale' not in synack['options'];effective=advertised['window'];assert effective<=65535
        outcomes[enabled]={'client':client_result,'server':server_result,'syn':syn,'synack':synack,
            'server_post_handshake_window_field':advertised['window'],'effective_receive_window':effective,'ss':diagnostics}
    print(json.dumps(outcomes))
"""
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)

Before opening connections, the client requests a receive buffer of 65536 bytes and the server listener requests 262144 bytes. The script records the effective socket-option values rather than assuming the requested sizes are used literally. Linux buffer accounting and limits matter: this run reported 131072 on the client and 425984 on the accepted server socket. Neither number should simply be equated with an advertised TCP window.

With scaling enabled in both namespaces, the captured client SYN offers shift one and the server SYN-ACK offers shift two. Their initial window fields remain unscaled. After setup, a server data segment advertises window field 53213. Applying the server’s shift gives 53213 times four, or 212852 bytes. The client socket diagnostic independently reports the corresponding send-window value.

The next fresh connection disables scaling in both namespaces while keeping the buffer requests and application exchange the same. Neither SYN carries a scale option, and the observed server receive window is 65535 bytes. Both trials send ping and receive pong, checking actual communication as well as negotiation. The comparison demonstrates the encoding limit without pretending that a four-byte exchange measures maximum throughput.

The fixture captures setup because the scale is fixed for that connection. Changing a system default later does not renegotiate an existing stream. Configuring appropriate receive-buffer behavior before connect or listen matters when it influences the scale selected during setup. A packet trace starting after the handshake needs the previously negotiated state before its raw window fields can be interpreted correctly.

Separate buffer allocation, advertised capacity, and observed performance

Record the relevant socket settings, negotiated shifts, current advertised window, congestion window, RTT, and application activity. Those describe different constraints. A large allocation can coexist with a smaller advertised window because the stack accounts for buffering and current occupancy. The experiment’s reported allocation and wire-level value intentionally make that distinction visible rather than hiding it behind one ambiguous “window size.”

If a transfer underperforms, check whether the receiver keeps consuming data and whether the sender has enough work to use its permitted flight size. Review losses and congestion behavior before changing buffer budgets. Larger allocations across many concurrent sockets also consume resources, so tune with workload evidence rather than maximizing every available limit.

The protocol limits the shift to fourteen, giving a representable window below one gibibyte. That encoding ceiling is not a requirement to allocate that much memory. Validate the deployed kernel and representative traffic after a change. This example verifies negotiation, direction, arithmetic, and a successful exchange; sustained capacity and application behavior need measurements suited to the real path.

Sources

  • RFC 7323 TCP Extensions, directional scale factors, SYN exceptions and receive-window calculation, checked 2026-09-12.
  • Linux tcp(7), receive buffers, window scaling and socket options before connection setup, checked 2026-09-12.
  • RFC 5681 Congestion Control, the separate limits imposed by congestion and receive windows, checked 2026-09-12.