Frontend Labs
⋯

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 may recv() 200 bytes once, or 37 then 163. Every TCP protocol must frame its own messages (HTTP uses Content-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.


Generated from real-life-example-osi.md · 8 Oct 2026 16:36