Two ways to send data across a network: one that guarantees delivery and one that doesn’t. Nearly every real-time system design hinges on knowing when to give up the guarantee.
Prerequisites: Networking 101 Time to read: ~18 minutes
The network is allowed to do horrible things to your data:
You need some protocol on top of raw IP. The question is which guarantees you want, and what they cost.
IP (Internet Protocol) does one job: get this packet to that address, probably.
An IP packet has a source IP, a destination IP, a TTL (hop counter, to kill packets that loop forever), and a payload. That’s essentially it. IP makes no promises about delivery, order, duplication, or timing.
This is a feature, not a flaw. Keeping the network layer dumb is why the internet scaled — routers just forward, and all the clever behaviour lives at the endpoints. (This is the “end-to-end principle,” and it’s why you can invent a new protocol without upgrading a single router.)
IPv4 vs IPv6: IPv4 addresses are 32 bits (~4.3 billion, exhausted years ago, hence NAT). IPv6 is 128 bits (effectively unlimited). For system design purposes: know they exist, know dual-stack is normal, and know that IPv6 removes the need for NAT.
Private ranges you should recognize instantly: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16.
Anything in a VPC lives here.
TCP turns IP’s chaos into a clean, ordered byte stream. It gives you five guarantees:
Client Server
│ ──── SYN, seq=x ─────────────────►│
│ ◄─── SYN-ACK, seq=y, ack=x+1 ─────│
│ ──── ACK, ack=y+1 ───────────────►│
│ connection established │
One full round trip before any data. → this is the cost you pay per connection, and why you pool them.
Closing takes a four-way exchange (FIN/ACK each direction), and the closing side then holds the
connection in TIME_WAIT for ~60 seconds (2× the max segment lifetime), to make sure stray packets
from the old connection don’t get confused with a new one on the same port pair.
🚨 This causes a real production problem. A busy server opening many short-lived outbound
connections accumulates tens of thousands of TIME_WAIT sockets and eventually runs out of
ephemeral ports (~28,000 by default). Symptom: “cannot assign requested address” under load. The
fix is connection reuse (keep-alive/pooling), not tuning. Check with ss -s.
Two different things people conflate:
📐 Slow start matters for latency. A new connection doesn’t start at full speed — it begins with ~10 packets (~14 KB) in flight and doubles each round trip. Transferring 1 MB over a fresh connection to a distant server takes several RTTs just to ramp up. Reusing a warm connection skips all of it. Another point for connection pooling.
TCP delivers bytes in order. If segment #2 is lost, segments #3–#100 sit in the receiver’s buffer, complete and useless, until #2 is retransmitted and arrives.
For a single file download, fine. For HTTP/2 — which multiplexes many independent requests over one TCP connection — one lost packet stalls every concurrent request. This is the specific flaw that motivated HTTP/3 moving to UDP. → HTTP Versions
UDP adds almost nothing to IP: source port, destination port, length, checksum. Send a datagram, hope it arrives.
No handshake (send immediately — zero setup latency), no retransmission, no ordering, no congestion control, no connection state (so one server can serve millions of clients without per-connection memory).
The insight beginners miss: for real-time data, a late packet is worse than a lost one.
In a voice call, if 20 ms of audio is lost, you want the next 20 ms immediately. TCP would stall the stream to retransmit audio that is now historical. UDP just moves on, and the codec conceals the gap. Nobody notices a 20 ms dropout; everybody notices a 300 ms stutter.
Same for live video, multiplayer game state (position updates supersede each other — the newest is all that matters), and metrics (losing one datapoint out of a million is noise).
| Use UDP for | Why |
|---|---|
| Voice/video calls (WebRTC) | Late data is useless; jitter is worse than loss |
| Live game state | Newer updates supersede older ones |
| DNS queries | Single small request/response; retry at the app layer is cheaper than a handshake |
| Metrics (StatsD) | Volume is huge, individual loss is irrelevant, and it must never block the app |
| QUIC / HTTP/3 | Reliability reimplemented in userspace, without TCP’s constraints |
| Service discovery, broadcast/multicast | TCP can’t multicast at all |
| TCP | UDP | |
|---|---|---|
| Connection | Handshake required (1 RTT) | None — send immediately |
| Reliability | Guaranteed, retransmits | None |
| Ordering | Guaranteed | None |
| Duplicates | Removed | Possible |
| Congestion control | Yes | No (you must implement it or be a bad citizen) |
| Header size | 20+ bytes | 8 bytes |
| Head-of-line blocking | Yes | No |
| Server state per client | Yes (memory, FDs) | None |
| Multicast/broadcast | No | Yes |
| Typical uses | HTTP, databases, SSH, email | Video/voice, gaming, DNS, QUIC, metrics |
⚖️ The trade-off in one line: TCP gives you correctness and costs you latency and tail behaviour. UDP gives you control and makes correctness your problem.
🚨 Note the last row of that table carefully: “UDP is faster” is a lazy statement. UDP has lower setup latency and no head-of-line blocking. Raw throughput on a healthy network is comparable — and TCP is often better, because its congestion control avoids the collapse that naive UDP causes.
QUIC runs a reliable, ordered, encrypted protocol on top of UDP, in userspace. It’s what HTTP/3 uses. It exists because TCP couldn’t be fixed — it’s implemented in operating system kernels and in middleboxes worldwide, so changes take a decade.
What QUIC gets you:
Cost: more CPU (userspace, less kernel/NIC offload), and some corporate networks block or throttle UDP.
1. Watch a handshake.
sudo tcpdump -i any -n 'tcp port 443 and host example.com' &
curl -s https://example.com > /dev/null
Identify the SYN, SYN-ACK, ACK, and then the encrypted data.
2. See TIME_WAIT accumulate.
ss -s # summary of socket states
for i in $(seq 1 200); do curl -s -o /dev/null http://example.com; done
ss -s # watch timewait climb
3. Feel head-of-line blocking. On Linux, add artificial loss and compare a multi-request HTTP/2 page load with and without it:
sudo tc qdisc add dev eth0 root netem loss 2%
# load a page, observe
sudo tc qdisc del dev eth0 root