TCP Deep Dive Primer

Our client just typed Enter on curl https://api.example.com/user/42. The previous primer watched a packet leave the NIC; this one stays inside L4 and answers what the connection itself is doing — the 3-way handshake to api.example.com:443, the sliding window of bytes, the FIN dance at the end. Five sections build the picture: the 3-way handshake (with an interactive packet-level walkthrough); reliability — sequence numbers, ACKs, retransmit, SACK, RTO; flow and congestion control — rwnd vs cwnd, slow-start, CUBIC vs BBR, buffer-bloat; lifecycle — TIME_WAIT, half-close, Nagle, keep-alive; and a quick reference.

01

The 3-way handshake — and why 3, not 2

curl just typed Enter. The kernel creates a socket, picks a random initial sequence number, sends a SYN toapi.example.com:443, and waits one RTT. Three packets later, both sides agree on the seq numbers, window sizes, and options that will govern the next million bytes of the connection.

Every TCP connection begins with three packets — SYN, SYN-ACK, ACK. The reason it's three and not two is that both sides need a synchronized initial sequence number (ISN), and each side's ISN needs to be acknowledged by the other before it can be trusted. Two packets would only synchronize one direction.

client                                 server
  |---- SYN seq=X ----------------------->|     (1)  client picks ISN=X
  |<--- SYN-ACK seq=Y, ack=X+1 -----------|     (2)  server picks ISN=Y,
  |                                       |          confirms it saw X
  |---- ACK ack=Y+1 --------------------->|     (3)  client confirms Y
  |                                       |
  |   ESTABLISHED   ←-- both sides   --→  ESTABLISHED

The cost of this is exactly one RTT before any application data can flow. On a LAN that's 200 µs; across an ocean it's 100–200 ms. That single RTT is why connection pooling matters so much in production HTTP clients — every fresh TCP handshake costs you a round trip before the first byte of payload, and then TLS adds another (or two, for TLS 1.2).

TCP connection life — handshake → data → FIN danceCLIENT (curl)SYN_SENTSERVER (api.example.com:443)LISTENSYNseq=X win=64240
Frame 1 — Client SYN. Opens half-connection.
1 / 8
Eight frames showing one entire TCP connection. Note the rhythm: every byte from the client gets an ACK number from the server, the receiver always advertises its rwnd (free buffer space), and the sender keeps a separate cwnd in its head that grows or collapses based on observed loss. When you tear down the connection, only one side ends up in TIME_WAIT— usually whoever calls close() first.

SYN flood and SYN cookies

After step 1 the server has to remember "there's a half-open connection from this 4-tuple, with my chosen ISN=Y, and I'm waiting for the client ACK." That state lives in theSYN queue (kernel sysctl net.ipv4.tcp_max_syn_backlog, default 1024–4096). An attacker who blasts SYNs from spoofed source IPs and never completes the handshake will overflow that queue, denying service to legitimate clients. The classic SYN-flood DoS.

The defense is SYN cookies (net.ipv4.tcp_syncookies = 1): when the SYN queue is full, the kernel stops allocating state. Instead, it chooses ISN=Y as a cryptographic hash of (4-tuple, MSS, timestamp, secret). When the client's ACK arrives with ack = Y + 1, the kernel re-derives Y from the 4-tuple, checks the hash, and rebuilds the connection state on the spot. No per-half-connection memory needed.

SYN queue vs accept queue

Once the third ACK arrives the connection is fully established; the kernel moves it from the SYN queue to the accept queue. accept() in user code pops from that queue. Its size is min(backlog, net.core.somaxconn), where backlog is the argument to listen() and somaxconn defaults to 4096 on recent kernels (was 128 for years — the cause of countless production stalls). Overflow drops the third ACK silently, and the client retries after RTO. Symptom: random connection latencies of 1, 3, 7 seconds.

TCP Fast Open

TFO (RFC 7413) skips the one-RTT cost on subsequent connections to the same server. The server hands the client an opaque TFO cookie during the first connection; on the next connection, the client sends SYN with data and the cookie in the same packet. The server validates the cookie and the application receives the request before the handshake completes — zero-RTT data on a TCP connection. Widely supported in Linux (net.ipv4.tcp_fastopen) but use of TFO from the public internet is rare; middleboxes drop the non-standard SYN+data, and HTTP/2 connection reuse + HTTP/3 0-RTT cover most of the use cases.

The takeaway. "TCP needs 3 packets because both sides must synchronize and acknowledge each other's initial sequence numbers — two would only cover one direction. The cost is one RTT before data. SYN queue holds half-opens (defended by SYN cookies); accept queue holds full ESTABLISHED connections waiting for accept(). Overflow of the latter causes mysterious 1/3/7s client latencies because the third ACK is dropped silently."

02

Reliability — sequence numbers, ACKs, retransmit, SACK

IP gives us a best-effort datagram service: packets can be lost, reordered, duplicated, or corrupted. TCP turns that into a reliable, in-order byte stream by numbering every byte and retransmitting anything the receiver doesn't acknowledge.

Every byte in a TCP connection has a sequence number. The 32-bit field in the TCP header counts bytes (not packets), and wraps around after 4 GB — which is why high-bandwidth long-lived connections use the PAWS (Protection Against Wrapped Sequence) timestamp option to disambiguate.

The receiver replies with an ACK number = "the next byte I expect." ACKs are cumulative: anack=1001 means "I have received everything up to byte 1000 and nothing beyond." A lost segment in the middle of a stream means every subsequent ACK still says "I'm still waiting for byte 1001," producing what the sender sees as duplicate ACKs.

Two ways to detect loss

TCP fires a retransmit by one of two mechanisms:

  1. Timeout (RTO). If a segment's ACK doesn't arrive within the retransmit timeout, retransmit it. The RTO is dynamic: RTO = SRTT + max(G, 4 · RTTVAR) where SRTT is the smoothed RTT and RTTVAR is the smoothed RTT variance (RFC 6298). Linux floors it at 200 ms and caps at 120 s; default ceiling is 60 s on most distros. After each timeout, RTO doubles (exponential backoff). Six consecutive timeouts and the connection is aborted (net.ipv4.tcp_retries2 = 15 for the data phase, which works out to roughly 15 minutes).
  2. Fast retransmit. Three duplicate ACKs in a row tell the sender "the receiver is getting later segments but is still missing this one," so retransmit immediately without waiting for RTO. Originally Reno; extended by NewReno and now SACK-based loss recovery.

SACK — selective acknowledgment

Pure cumulative ACKs waste bandwidth when there's a single loss in a burst — the sender doesn't know whether to retransmit just the missing segment or everything after it.SACK (RFC 2018, negotiated in SYN options) lets the receiver report "I have bytes 1001–2000 and also 3001–8000; I'm missing 2001–3000." The sender retransmits exactly the missing range and stops there. Without SACK, lossy connections cascade into multiple-RTT recovery windows.

DSACK (RFC 2883, duplicate SACK) goes the other way: it lets the receiver report "I just got 1001–2000 asecond time." The sender learns its retransmit was spurious — usually because the original was just slow, not lost — and can be less aggressive about cutting cwnd on future false positives.

TLP — Tail Loss Probe

Modern Linux (~3.10+) ships TLP for the tail-loss case: when the application sends a small burst and the last segment is lost, there's no later segment to trigger duplicate ACKs — only RTO timeout can save you, costing hundreds of ms. TLP fires a single probe after about 2 · SRTT (much faster than RTO) to either get a fast ACK or trigger one via duplicate ACK. Dramatic improvement on request/response workloads where most segments are at the tail.

A loss recovery trace

send seq=1, 1001, 2001, 3001, 4001, 5001    (5 segments, 1 KB each)
recv ack=1001                                (got first)
recv ack=2001                                (got second)
recv ack=2001  [SACK 3001-5001]              (DUP-ACK 1; third was lost)
recv ack=2001  [SACK 3001-6001]              (DUP-ACK 2)
recv ack=2001  [SACK 3001-6001]              (DUP-ACK 3 → fast retransmit!)
fast-retransmit seq=2001                     (only the missing one)
recv ack=6001                                (now all caught up)
cwnd halved (Reno) — congestion-avoidance phase from here

The takeaway. "TCP numbers every byte (32-bit, wraps with PAWS protection), ACKs are cumulative (next-expected-byte). Loss is detected by RTO timeout (slow, tens to hundreds of ms) or three duplicate ACKs (fast retransmit). SACK reports holes so only the missing range is resent; DSACK flags spurious retransmits; TLP probes tail losses so a dropped last segment doesn't cost a full RTO."

03

Flow + congestion control — rwnd, cwnd, Reno → CUBIC → BBR

Two different problems share the same mechanism: a sliding window. Flow control protects the receiver from being drowned. Congestion control protects the network from being drowned. The sender obeys both at once and ends up paced by whichever is tighter.

rwnd — receiver flow control

Every ACK header advertises a receive window(rwnd): "I have this much free buffer space right now." The sender promises never to put more unacknowledged bytes on the wire than the receiver advertised. The raw 16-bit field caps at 64 KB, which is laughable for modern bandwidth — so the window scaling option (RFC 7323, negotiated in SYN) lets the receiver multiply the advertised value by up to 214, extending the effective window to 1 GB.

If the application doesn't call read() for a while, the kernel buffer fills, advertised rwnd shrinks to zero, and the sender stops. This is good (lossless backpressure) until the application has a transient stall and the connection sits idle — the sender will probe with zero-window probes until rwnd reopens. Pathology of the year: a slow consumer can stall an entire pipeline this way without any "errors" appearing.

cwnd — congestion control

The sender also maintains a congestion window(cwnd) that's an internal estimate of how much the network (not the receiver) can absorb. In-flight bytes are capped at min(rwnd, cwnd). Different congestion control algorithms differ only in how cwnd evolves.

The classic Reno / NewReno / CUBIC family follows three phases:

  1. Slow start. cwnd starts at tcp_init_cwnd (Linux default 10 segments × 1460 B MSS ≈ 14 KB — RFC 6928) and doubles every RTT (one MSS per ACK). Exponential growth until cwnd hits ssthresh or a loss is observed.
  2. Congestion avoidance. cwnd grows linearly, +1 MSS per RTT. AIMD: additive increase, multiplicative decrease.
  3. Loss reaction. Reno halves cwnd. CUBIC remembers the cwnd at which loss occurred (W_max) and grows back toward it on a cubic curve — flat near the peak, accelerating away from it. Result: faster recovery on high-BDP links than Reno's pure AIMD.

Linux defaults to CUBIC since 2.6.19 (2006). Inspect with sysctl net.ipv4.tcp_congestion_control; choose with the same.

BBR — model-based, loss-agnostic

Google introduced BBR (Bottleneck Bandwidth and Round-trip propagation time) in 2016 as a fundamentally different approach. CUBIC and Reno are loss-based: they fill the bottleneck queue until a packet drops and treat the drop as signal. That works fine on a 10 ms LAN; on a 200 ms cross-ocean link with deep middle-box buffers, by the time the queue fills and a packet drops, latency has exploded ("buffer bloat") and CUBIC has to back off massively.

BBR continuously estimates the bottleneck bandwidth (BtlBw) from the rate of incoming ACKs and the round-trip propagation delay (RTprop) from the minimum RTT observed. Sending rate is paced — packets spread out smoothly in time, not windowed in bursts — and held near BDP = BtlBw × RTprop. On lossy long-fat networks (transatlantic, mobile), BBR routinely delivers 2–10× the throughput of CUBIC. On a clean LAN they tie.

BDP — the back-of-envelope

The bandwidth-delay product tells you how much data needs to be "in flight" to saturate a link:

BDP = bandwidth × RTT
1 Gbps × 100 ms  = 12.5 MB    (cross-country US)
10 Gbps × 200 ms = 250 MB     (transatlantic 10G)
100 Mbps × 30 ms = 375 KB     (residential broadband)

If your sender keeps fewer bytes than the BDP in flight, you leave bandwidth on the table — this is "RTT-limited." Reasons: rwnd too small (forgot window scaling, defaultnet.ipv4.tcp_rmem too low), cwnd too small (in slow start, or recovering from loss), or the application stalls between writes. Tuning net.ipv4.tcp_rmem andtcp_wmem is the first port of call when long-fat connections under-deliver.

Buffer bloat and qdisc

Long FIFO queues in switches/routers and consumer Wi-Fi APs absorb packets instead of dropping them, hiding congestion from TCP and inflating RTT into the seconds. Fixes: BBR (which paces and watches RTT rather than loss) and fq_codel / cakeqdiscs on Linux (which actively drop from long queues to give TCP an honest loss signal). sysctl net.core.default_qdisc picks the qdisc; modern distros default to fq_codel.

The takeaway. "Two windows control the sender: rwnd (receiver tells me their free buffer; flow control) and cwnd (I estimate network capacity; congestion control). In-flight is capped at min(rwnd, cwnd). CUBIC is loss-based and dominant; BBR is model-based, paces at BtlBw × RTprop, and wins on lossy long-fat links by not filling middle-box buffers. Use BDP to know whether you're bandwidth- or RTT-limited."

04

Lifecycle — TIME_WAIT, FIN, Nagle, keep-alive

Tearing a connection down turns out to be more interesting than opening it. The FIN dance, the famous TIME_WAIT, Nagle's algorithm fighting delayed ACK, and keep-alive — all of these are where production code most often misbehaves.

The FIN dance and TIME_WAIT

TCP is bidirectional, so closing is bidirectional too. Each side sends a FIN when it's done writing; each side ACKs the other's FIN. Four packets total in the most general case (FIN, ACK, FIN, ACK), often compressed to three when the server piggybacks its FIN on the ACK of the client's FIN.

client                                 server
  |---- FIN ---------------------------->|
  |<--- ACK -----------------------------|     client → FIN_WAIT_2
  |<--- FIN -----------------------------|
  |---- ACK ---------------------------->|     server → CLOSED
  |                                       |
  |  TIME_WAIT  (hold 4-tuple 2·MSL)     |
  |       ↓ 60s later                     |
  |     CLOSED                            |

The side that calls close() first enters TIME_WAIT and holds the 4-tuple (local_ip, local_port, remote_ip, remote_port) for 2 · MSL — twice the Maximum Segment Lifetime, 60 s by default on Linux (net.ipv4.tcp_fin_timeout). Two reasons:

  1. Absorb the duplicate FIN if the final ACK was lost. Without TIME_WAIT, the next connection on the same 4-tuple would see a stray FIN from the dead connection and tear itself down.
  2. Make sure no delayed segment from the old connection collides with a new connection reusing the same 4-tuple (the "old duplicate segment" problem).

In production this matters when the same client makes many short HTTP/1.0-style connections: TIME_WAIT entries accumulate, the 4-tuple space runs out, connect() starts failing with EADDRNOTAVAIL. Inspect with ss -tan state time-wait | wc -l; legitimate mitigations:

  • Connection pooling. The cure, not the symptom. Reuse one connection for many requests; HTTP/1.1 keep-alive, HTTP/2 multiplexing, gRPC pooled channels.
  • SO_REUSEADDR. Allows binding a listening socket to a port that has TIME_WAIT entries on it.
  • SO_REUSEPORT. Allows multiple sockets on the same port — kernel load-balances incoming connections across listener processes. Common pattern for sharded servers (Nginx, Envoy worker processes).
  • net.ipv4.tcp_tw_reuse = 1. Lets new outbound connections reuse a TIME_WAIT-state local 4-tuple if it's safe (PAWS timestamps prove the new SYN is genuinely new). Outbound only, and only with timestamps; safe in production.

Do not use net.ipv4.tcp_tw_recycle. It was removed in Linux 4.12 because it broke any client behind a NAT — multiple clients shared a public IP, their timestamps were not monotonic across the NAT, and the server would silently drop SYNs. If you see this enabled on a server, treat it as a bug.

Half-close and shutdown(SHUT_WR)

close(fd) shuts down both directions. shutdown(fd, SHUT_WR) sends a FIN but keeps the read side open, so you can still receive a response after signaling "I'm done sending." HTTP/1.1 historically used this to mark the end of the request body before reading the response; gRPC does it for client-streaming RPCs.

Nagle vs delayed ACK

Two latency-versus-throughput optimizations that interact terribly when both are on:

  • Nagle's algorithm (on by default): the sender holds small writes until either MSS bytes are queued or the previous segment is ACK'd. Cuts the "tinygrams" problem (40 bytes of header for every 1 byte of payload). Disable with setsockopt(TCP_NODELAY, 1).
  • Delayed ACK (on by default): the receiver delays sending a pure ACK by ~40 ms hoping to piggyback it on response data. Cuts ack traffic in half on typical request/response streams.

Combined, they cause a textbook pathology: client does write(small_header) then write(small_body). The first goes out; Nagle holds the second waiting for an ACK. The receiver delays the ACK 40 ms waiting for more data to piggyback on. Result: every request adds 40 ms of latency. The fix is eitherTCP_NODELAY or — better — coalesce your writes in user space (one writev() call, or build the full request in a buffer before send()).

TCP keep-alive

SO_KEEPALIVE tells the kernel to send keep-alive probes on an idle connection so dead peers can be detected. The defaults are useless: net.ipv4.tcp_keepalive_time = 7200 (2 hours of idle before the first probe!), tcp_keepalive_intvl = 75, tcp_keepalive_probes = 9 — total ~2h 11m to detect a dead peer. For anything network-facing, set per-socket TCP_KEEPIDLE / TCP_KEEPINTVL / TCP_KEEPCNT to something sane (60 s idle, 10 s interval, 3 probes → fail in 90 s). gRPC ships its own application-level keep-alive for the same reason.

Connection draining

When a load balancer takes a backend out of rotation, it shoulddrain: stop sending new connections, let the existing ones finish (or close them with a clean FIN after a deadline). The wrong way is to slam the backend down hard — open connections get a RST, in-flight requests fail. The right way: signal unhealthy in the health check, wait for keep-alive to expire, send FIN. Kubernetes preStop hooks exist for exactly this.

The takeaway. "Whoever closes first ends up in TIME_WAIT for 2·MSL (60 s default) to absorb stragglers. The cure for TIME_WAIT exhaustion is connection pooling, not tcp_tw_recycle (which is removed and broke NAT). Nagle + delayed ACK is a textbook 40 ms latency pathology; fix by coalescing writes, not by disabling Nagle. Default TCP keep-alive is 2 hours of idle — set per-socket TCP_KEEPIDLE for anything that matters."

05

Quick reference

Six questions worth being able to reason about cold, and five red flags to spot in a code review.

Why does TCP need a 3-way handshake (not 2)?

Both sides need a synchronized initial sequence number (ISN), and each side's ISN must be acknowledged by the other before it can be trusted. Two packets only synchronize one direction (the client's ISN reaches the server, but the server's ISN is never acknowledged). The third packet closes the loop. Cost: 1 RTT before any application data, on top of which TLS adds another 1–2.

What's the difference between rwnd and cwnd?

rwnd is the receiver'sadvertisement of free buffer space, carried in every ACK header — flow control. cwnd is thesender's internal estimate of network capacity, never seen on the wire — congestion control. In-flight unacknowledged bytes are bounded by min(rwnd, cwnd). Either one being too small bottlenecks throughput.

CUBIC vs BBR — when does each win?

CUBIC is loss-based: fills the bottleneck queue until a packet drops, then backs off on a cubic curve. Wins on clean low-latency LAN/datacenter links. BBR is model-based: estimates BtlBw and RTprop, paces at BtlBw × RTprop, ignores loss as a congestion signal. Wins on lossy long-fat links (transoceanic, mobile, residential broadband with buffer bloat) where CUBIC would either fill huge middle-box buffers or collapse on random packet loss. Google reported 2–14× throughput gains deploying BBR on YouTube; on a clean LAN they tie.

What is TIME_WAIT and how do you mitigate it in production?

The side that calls close() first holds the 4-tuple for 2·MSL (Linux default 60 s, net.ipv4.tcp_fin_timeout) to absorb delayed retransmits and prevent old segments from colliding with a reused 4-tuple. Symptoms of exhaustion:ss -tan state time-wait | wc -l huge, connect() failing with EADDRNOTAVAIL. Fix: connection pooling (HTTP/1.1 keep-alive, HTTP/2, gRPC), andnet.ipv4.tcp_tw_reuse = 1 for outbound. Never use tcp_tw_recycle — removed in kernel 4.12 because it broke NAT.

What is the Nagle + delayed-ACK pathology?

Nagle holds small writes waiting for either MSS bytes or an ACK of the previous segment. Delayed ACK delays a pure ACK ~40 ms hoping to piggyback on data. When the application does back-to-back small writes (header then body), Nagle holds the second, the receiver delays the ACK, and every request adds ~40 ms of latency. Fix: coalesce writes in user space (writev() or a buffered send()) rather than just setting TCP_NODELAY, which fixes the symptom while leaving the tinygrams problem on the table.

How do you measure if a connection is bandwidth- or RTT-limited?

Compute BDP = bandwidth × RTT. If your sender keeps fewer in-flight bytes than the BDP, you're RTT-limited (small rwnd, small cwnd, or the application stalls between writes). If you keep BDP bytes in flight and observe loss / backed-off cwnd, you're bandwidth-limited. Inspect withss -ti (shows cwnd, rwnd, rtt, loss); profile withtcpdump + tcptrace or iperf3.

Red flags in code review

  • Disabling Nagle reflexively. setsockopt(TCP_NODELAY, 1) followed by ten small writes per request. You traded one 40 ms stall for ten 40-byte-header tinygrams on the wire. Coalesce writes (writev(), or build a buffer first) instead.
  • tcp_tw_recycle = 1 in production sysctl.Removed from the kernel in 4.12; broke any client behind a NAT. Treat as a bug. The correct knob is tcp_tw_reusefor outbound connections.
  • Misuse of SO_LINGER with timeout=0. Forces a RST instead of a clean FIN on close. The peer sees connection reset, in-flight data is lost. Only ever do this in test shutdown paths, never on real traffic.
  • Application protocol that ping-pongs single-byte messages. Each request costs one RTT; throughput ≤ payload / RTT regardless of bandwidth. A 50 ms RTT means 20 req/s per connection no matter how fat the pipe. Pipeline / multiplex or accept the latency budget.
  • No connection pooling — fresh TCP+TLS per request.1 RTT for TCP + 1–2 RTT for TLS = 2–3 RTT before the first payload byte. On a 100 ms cross-continent link that's 300 ms of overhead per request. HTTP/1.1 keep-alive, HTTP/2, or HTTP/3 fixes this; rolling your own client without it leaves performance on the floor.