Part 6 — Making TCP fast (and why yours isn't)
TCP works. This part is about why it's sometimes slow, and it explains most real-world performance complaints.
Chapter 24 — Flow control: the sliding window
In one sentence
The receiver continuously tells the sender "you may send this many more bytes before waiting for me", which stops a fast sender from drowning a slow receiver.
The problem
A powerful server sends to a small IoT device. If the server blasts at full speed, the device's memory fills and everything is dropped — wasting the whole transfer. The receiver needs a way to say "slow down".
The idea, in plain language
A conveyor belt with a bucket at the end:
SENDER ---> [ items on the belt ] ---> RECEIVER's bucket
|
"my bucket has room for 8 more" <---+
The receiver announces how much room it has left. The sender never sends more than that.
How it actually works
Every TCP packet carries a window field: "I can accept this many more bytes right now."
Bytes: ...already sent | sent, not yet | may send | can't send
and acknowledged | acknowledged | now | yet
---------------------+-----------------+---------------+------------->
^ ^
oldest unacked end of window
<---------------- WINDOW (what the receiver allows) ---->
As acknowledgements arrive, the whole window slides to the right — hence "sliding window". If the receiving application stops reading, the window shrinks toward zero and the sender pauses automatically. You get backpressure for free.
The 64 KB wall (and why window scaling matters)
The window field in the TCP header is only 16 bits — maximum 65,535 bytes.
Now here's the crucial formula:
maximum throughput = window size / round-trip time
| Round-trip time | Max speed with a 64 KB window |
|---|---|
| 1 ms (same building) | ~500 Mbit/s |
| 30 ms (same country) | ~17 Mbit/s |
| 100 ms (intercontinental) | ~5 Mbit/s |
| 300 ms (satellite) | ~1.7 Mbit/s |
Read that table again. On a 100 ms link, a 64 KB window caps you at 5 Mbit/s no matter how fast your connection is. You could have a 10 Gbit/s fibre and get 5 Mbit/s.
The fix is the window scale option, negotiated in the handshake (Chapter 21). It multiplies the window by a power of two, allowing windows up to about 1 GB. It's on by default everywhere — but if a middlebox strips it, you get the table above, and this is a real and maddening cause of "the link is fast but transfers are slow".
Bandwidth-delay product
The window you need is called the bandwidth-delay product (BDP):
BDP = bandwidth x round-trip time ("how much data fits in the pipe")
Example: 1 Gbit/s link, 80 ms round trip
= 1,000,000,000 bits/s x 0.08 s
= 80,000,000 bits = 10 MB
You need a ~10 MB window to keep that pipe full.
This is why long-distance high-speed transfers need big buffers, and why default settings that are fine locally are terrible internationally.
Zero window
If the receiving application stops reading entirely, the window hits zero and the sender stops. The sender then sends periodic tiny "window probes" to ask "any room yet?", so a lost window update can't deadlock the connection.
Seeing "TCP ZeroWindow" in Wireshark means: the receiving application is too slow. It is not a network problem. Very useful to know — it immediately points you at the app, not the network.
Practice (25 min)
A. See the window in your own traffic:
sudo tcpdump -i any -n 'tcp port 443' -c 20
Look at the win value on each packet. After the handshake, the numbers are
scaled — multiply by 2^(scale factor from the handshake) for the real value.
B. Look at live connection details (Linux — this is a great command):
ss -tin
Look for wscale:7,7 (window scaling active), rtt: (measured round-trip time),
cwnd: (Chapter 25), and send X Mbps (its own throughput estimate).
C. Prove the window/RTT formula. Compare a download from something nearby with something far away:
curl -o /dev/null -s -w 'speed: %{speed_download} bytes/s time: %{time_total}s\n' \
https://speed.cloudflare.com/__down?bytes=10000000
Then compare ping times to servers at different distances. The relationship
between latency and achievable throughput will be visible.
D. Check your system's buffer limits:
sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem net.core.rmem_max # Linux
sysctl net.inet.tcp.recvspace net.inet.tcp.sendspace # macOS
The third number in tcp_rmem is the maximum receive buffer. If it's smaller than
your BDP, that's your speed limit.
Build it in Go (25 min) — drive the window to zero
Flow control is easiest to understand from the sender's side: when the
receiver stops reading, the sender's Write stops returning. This lab
connects a fast writer to a deliberately slow reader (4 KB every 500 ms) with
small buffers, so the window fills within a second.
// zerowindow: a receiver that reads slowly, so you can watch flow control
// (Chapter 24) fill the receive buffer, shrink the window to zero, and make
// the sender's Write block.
//
// go run ./zerowindow
// # meanwhile: ss -tnoi '( sport = :7071 or dport = :7071 )' (Linux)
// # netstat -an -p tcp | grep 7071 (macOS: watch Recv-Q / Send-Q)
package main
import (
"fmt"
"net"
"time"
)
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:7071")
if err != nil {
panic(err)
}
go func() { // the SLOW receiver: 4 KB every 500 ms
c, _ := ln.Accept()
c.(*net.TCPConn).SetReadBuffer(64 * 1024) // a small window, so it fills fast
buf := make([]byte, 4096)
for {
time.Sleep(500 * time.Millisecond)
if _, err := c.Read(buf); err != nil {
return
}
}
}()
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
panic(err)
}
c.(*net.TCPConn).SetWriteBuffer(64 * 1024)
chunk := make([]byte, 16*1024)
total := 0
for i := 0; i < 64; i++ {
start := time.Now()
n, err := c.Write(chunk) // returns once the bytes are in OUR send buffer
if err != nil {
fmt.Println(err)
return
}
total += n
if wait := time.Since(start); wait > 50*time.Millisecond {
fmt.Printf("write #%-2d BLOCKED %-6v (total %4d KB) <- receiver's window is full\n",
i, wait.Round(time.Millisecond), total/1024)
} else {
fmt.Printf("write #%-2d instant (total %4d KB)\n", i, total/1024)
}
}
}
The program's own output shows writes going from instant to blocked. The kernel's view is the interesting part (Linux, during the run):
$ ss -tnoi '( sport = :7071 or dport = :7071 )'
ESTAB 79872 0 127.0.0.1:7071 127.0.0.1:51062 <- receiver: 78 KB unread (Recv-Q)
ESTAB 0 128000 127.0.0.1:51062 127.0.0.1:7071 timer:(persist,701ms,0)
... backoff:4 ... rwnd_limited:6008ms(100.0%) ... notsent:128000
How to read it:
- Receiver
Recv-Q 79872: bytes the kernel holds that the application hasn'tReadyet. The window it advertises shrinks by exactly this amount. - Sender
Send-Q 128000/notsent:128000: bytes the sender has accepted fromWritebut can't send, because the window is closed. timer:(persist,...): the zero-window probe timer from this chapter. The sender periodically asks "is there room yet?", backing off each time (backoff:4).rwnd_limited:...(100.0%): Linux is telling you directly that the whole connection was limited by the receiver's window, not by the network. When you see this in production, look at the receiving application, not the network.
Exercises:
- Change the reader's sleep to 0 and watch every write become instant. The network didn't change; the application did.
- Remove the
SetReadBuffer/SetWriteBuffercalls. Linux autotunes the buffers upwards, so it takes longer to block. How many KB get through before the first blocked write? - In a real service this looks like a consumer that's too slow, for example a log shipper behind a slow disk. Find the equivalent in the Linux guide's I/O stack chapter: a slow disk produces exactly this kind of backpressure.
Common confusions
- "Flow control vs congestion control?" Flow control protects the receiver (Chapter 24). Congestion control protects the network (Chapter 25). Both limit the sender; the sender obeys whichever is smaller.
- "Bigger window is always better?" For throughput on a long link, yes. But oversized buffers in the network cause bufferbloat (Chapter 27).
Check yourself
- What does the window field tell the sender?
- Why does a 64 KB window limit you to ~5 Mbit/s on a 100 ms link?
- What does "TCP ZeroWindow" tell you about where the problem is?
- Calculate the BDP for a 100 Mbit/s link with 40 ms RTT.
Further reading
- Video: "TCP Flow Control" — Practical Networking.
- Article: "TCP Window Scaling" — search for the Cloudflare or Packetlife explainer.
- Tool:
ss -tinon Linux — the single most informative command for live TCP.