Part 6 — Layer 4: Transport
Job
Turn the unreliable, host-to-host packets of L3 into process-to-process communication, identified by ports. TCP adds reliability, ordering, flow control, and congestion control. UDP adds only ports and a checksum.
In our story
The browser asks the kernel to connect() to 198.51.100.20:443. The kernel
picks an ephemeral source port (54012) and performs the three-way
handshake:
Laptop 192.168.1.23:54012 ALB 198.51.100.20:443
│ │
t=0 │──── SYN seq=3001829117 win=65535 ──▶│
│ options: MSS=1460, SACK-permitted, │
│ WScale=6, Timestamps │
│ │
t=40ms│◀─── SYN-ACK seq=901120044 ack=3001829118 MSS=1460 ... ──│
│ │
t=40ms│──── ACK ack=901120045 ─────────────────────────────▶ │ (connection ESTABLISHED)
│──── [immediately followed by TLS ClientHello] ─────────▶ │
One full round trip (RTT ≈ 40 ms) has passed and no application data has moved yet. That's the cost of reliability, and the reason connection reuse matters so much.
The connection is now identified everywhere by its 5-tuple:
(protocol=TCP, src=192.168.1.23, sport=54012, dst=198.51.100.20, dport=443)
…which the home router's NAT rewrites to (TCP, 203.0.113.7, 61001, 198.51.100.20, 443) for everyone on the Internet side.
Important: the ALB is an L7 proxy, so this TCP connection ends at the
ALB. The ALB opens a separate TCP connection from 10.0.1.15:<ephemeral>
to the pod 10.0.2.77:8080, and it usually reuses one it already has open.
The pod never sees your laptop's TCP connection (or your IP, except in the
X-Forwarded-For header).
How it works
1. TCP vs UDP.
| TCP | UDP | |
|---|---|---|
| Connection | Yes (handshake, state) | No |
| Reliability and ordering | Yes: sequence numbers, ACKs, retransmissions | No: the app handles loss |
| Flow/congestion control | Yes | No (the app must be polite) |
| Header | 20–60 B | 8 B |
| Message boundaries | No: it's a byte stream | Yes: one send = one datagram |
| Used by | HTTP/1.1, HTTP/2, TLS, Postgres, SSH | DNS, DHCP, QUIC/HTTP/3, video calls, games, VXLAN |
"TCP is a byte stream" means: if you
send()100 bytes twice, the other side mayrecv()200 bytes once, or 37 then 163. Every TCP protocol must frame its own messages (HTTP usesContent-Length/ chunked encoding; gRPC uses a length prefix). Forgetting this is a classic bug in hand-written protocols.
2. The TCP header and the fields that matter:
Src port 54012 | Dst port 443
Sequence number — byte offset of this segment's first byte in the stream
Ack number — "I've received everything up to byte N-1, send N next"
Flags — SYN, ACK, FIN (graceful close), RST (abort), PSH, ECE/CWR (ECN)
Window — receive window: how many more bytes I can buffer (flow control)
Options — MSS, Window Scale, SACK, Timestamps
3. Reliability. Every byte has a sequence number. The receiver ACKs what it has received. If an ACK doesn't arrive within the retransmission timeout (RTO), or the sender sees three duplicate ACKs (fast retransmit), it sends the data again. Linux's minimum RTO is 200 ms, and the initial RTO before any RTT measurement (the SYN) is 1 second. So:
One lost packet in the wrong place costs 200 ms to 1 s or more. A lost SYN adds a full second to connection setup. This is why 0.1% packet loss damages p99 latency far more than you'd expect.
4. Flow control vs congestion control. People mix these up:
| Flow control | Congestion control | |
|---|---|---|
| Protects | The receiver (don't overflow its buffer) | The network (don't overflow routers in between) |
| Mechanism | Receiver advertises a window (rwnd) |
Sender estimates a congestion window (cwnd) |
| Effective send limit | min(rwnd, cwnd) |
Slow start: a new connection starts with cwnd = 10 segments (~14 KB) and
roughly doubles every RTT until it sees loss (CUBIC, the Linux default) or until
it models the path's bandwidth (BBR). Consequence:
Transferring 200 KB on a fresh connection, RTT 40 ms, ignoring loss:
RTT 1: 14 KB RTT 2: 28 KB RTT 3: 56 KB RTT 4: 112 KB → ~4 RTTs ≈ 160 ms
On a warmed-up, reused connection: ~1 RTT ≈ 40 ms
5. Bandwidth-delay product (BDP). To fill a pipe you need bandwidth × RTT
bytes in flight. 1 Gb/s × 100 ms = 12.5 MB. If socket buffers or windows are
smaller than that, a single connection can't use the bandwidth you're paying
for. That's why cross-region bulk copies use parallel streams.
6. Closing. A graceful close is FIN → ACK → FIN → ACK. The side that closes
first enters TIME_WAIT for 2×MSL (60 s on Linux) to absorb stray
packets. A busy client that opens and closes thousands of short connections
can run out of ephemeral ports (Linux default range 32768–60999, about 28k)
because of TIME_WAIT. RST is an abort: "this connection doesn't exist" or
"I'm giving up". It's what you see as ECONNRESET / "connection reset by peer".
7. Ports and sockets in the kernel. A server listen()s on :8080. The
kernel completes the handshake without involving your app and queues the
connection in the accept backlog. Your app calls accept() to get it. If
your app is too slow to accept, the backlog fills and new SYNs get dropped.
To clients that looks like a 1–3 s connect delay.
8. L4 load balancers vs L7 proxies (a preview of Part 15). An L4 load balancer (AWS NLB, IPVS, Maglev) routes on the 5-tuple and passes the TCP stream through without reading it. An L7 proxy (ALB, nginx, Envoy) ends your TCP connection, reads the HTTP, and starts a new connection to the backend.
Try it yourself
# All TCP connections with state, process, and per-connection internals (Linux)
ss -tanp
ss -tni dst 198.51.100.20 # shows rtt, cwnd, retrans, mss per connection
# macOS equivalent (less detail)
netstat -anv -p tcp | head -30
lsof -nP -iTCP -sTCP:ESTABLISHED
# Capture just the handshake and teardown packets
sudo tcpdump -i en0 -n 'host shop.example.com and (tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0)'
# Is a port reachable? (L4 check, no HTTP involved)
nc -vz shop.example.com 443
# System-wide retransmission and drop counters
netstat -s | grep -iE 'retrans|listen|overflow|reset'
# Ephemeral port range and TIME_WAIT count (Linux)
sysctl net.ipv4.ip_local_port_range
ss -tan state time-wait | wc -l
What breaks
| Symptom | L4 cause |
|---|---|
Connection refused (immediately) |
Host reachable, nothing listening on that port → kernel sends RST |
| Connect hangs then times out | Firewall/security group silently dropping SYNs, wrong route, or host down |
| Random 1 s or 3 s connect delays | SYN dropped (backlog overflow, conntrack full, packet loss) → SYN retransmit after 1 s |
connection reset by peer mid-request |
Peer process crashed, idle timeout reaped the connection, or a middlebox injected an RST |
EADDRNOTAVAIL / "cannot assign requested address" |
Ephemeral port exhaustion (no connection pooling, TIME_WAIT pile-up, NAT gateway port limits) |
| Single stream won't exceed ~50 Mb/s cross-region | BDP > window/buffer size |
| Requests take exactly +40 ms (or +200 ms) | Nagle's algorithm + delayed ACK interacting. Set TCP_NODELAY for request/response protocols |
nf_conntrack: table full, dropping packet in kernel log |
Linux connection-tracking table exhausted on a busy node/NAT box |
War story: "Black Friday, outbound payments start failing"
The checkout service called a payment provider for every order. It opened a
new HTTPS connection per request and closed it immediately. All outbound
traffic went through one cloud NAT gateway, which supports about 55,000
simultaneous connections per unique destination. At peak traffic, TIME_WAIT
plus in-flight connections exhausted that limit. New connections failed with
timeouts and EADDRNOTAVAIL, and payments failed. Fix: an HTTP client with
a connection pool and keep-alive (100 long-lived connections instead of tens of
thousands of short ones), plus more NAT IPs. Lesson: connection reuse isn't
a micro-optimisation. It's a capacity property of your system. Review every
outbound HTTP client for pooling.
Why it matters to you
- Every new TCP connection costs 1 RTT, plus slow start. Every new TLS connection costs another RTT. Connection pooling and keep-alive are the cheapest latency wins available.
- Packet loss → retransmission timeouts → tail latency. Your p99 is often a network story.
- "Refused" and "timed out" point to different teams. Refused means the host answered, so the service is down. Timeout means something dropped the packet, so look at firewalls, routing, and security groups.
- Timeouts must be layered deliberately (connect timeout < request timeout < upstream LB idle timeout). See the war story in Part 7.
- Ports are a finite resource on clients, NAT gateways, and conntrack tables. High-fan-out services need pooling by design.
Go deeper in the series
Each idea in this part has a Go lab in the TCP/IP guide:
| Idea here | Lab there |
|---|---|
| Refused vs timed out | dialcheck, Ch 21 |
| TCP is a byte stream | stream, Ch 22 |
| TIME_WAIT / CLOSE_WAIT | closewait, Ch 23 |
| Flow control | zerowindow, Ch 24 |
| Nagle + delayed ACK (the +40 ms) | nagle, Ch 26 (measured: 44 ms) |
| Accept queue full ("slow connects, idle app") | acceptq, Ch 52 |
| Bandwidth-delay product | bdp, Ch 59 |
And the operating-system side, where 10,000 waiting connections need about 16 threads in Go: Linux guide, Chapters 73 and 78.