Two transports, two philosophies: a reliable ordered stream versus a fast, lossy datagram. Almost everything above the network layer inherits one of these.
Both TCP and UDP sit on top of IP, which by itself is unreliable: packets can be dropped, duplicated, delayed, or reordered. TCP and UDP make opposite bets about what to do with that.
| Property | TCP | UDP |
|---|---|---|
| Connection | Yes β handshake first | No β just send |
| Reliability | Retransmits lost data | None β app's problem |
| Ordering | In-order delivery | May arrive reordered |
| Boundaries | Byte stream (no message frames) | Preserves datagram boundaries |
| Flow / congestion control | Built in | None |
| Header size | 20+ bytes | 8 bytes |
| Head-of-line blocking | Yes | No |
TCP is connection-oriented: before any data moves, both sides synchronize sequence numbers so they can detect loss and reordering. That costs one round trip before the first byte.
UDP has no equivalent. You call sendto() and the datagram leaves immediately β zero setup RTT.
TCP's "reliability" is really four mechanisms working together:
Two different problems, often confused:
| Flow control | Congestion control | |
|---|---|---|
| Protects | The receiver from being overrun | The network from being overrun |
| Mechanism | Advertised receive window (rwnd) | Congestion window (cwnd) |
| Signal | Receiver's buffer space | Packet loss / delay (Reno, CUBIC, BBR) |
The effective send rate is bounded by min(rwnd, cwnd). Congestion control starts in
slow start (exponential ramp) then switches to congestion avoidance (linear), backing off
hard on loss. This is why a fresh connection is slow β cwnd has to warm up.
UDP's API is almost nothing β no connect, no accept, no stream. You get message boundaries for free.
import socket
# UDP: connectionless, boundary-preserving, no delivery guarantee
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(b"PING", ("10.0.0.5", 9999)) # fire and forget β may never arrive
data, addr = sock.recvfrom(1500) # one recv == one datagram
print(data, "from", addr)
Compare TCP, where you connect first and read an undelimited stream:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("10.0.0.5", 9999)) # triggers the 3-way handshake
sock.sendall(b"hello world") # bytes, not messages
chunk = sock.recv(4096) # may return a partial "message" β you frame it yourself
recv() can return half a message or two messages
glued together. Every TCP protocol invents its own framing β length prefixes, delimiters, or a
self-describing format like protobuf on the wire.
| Use TCP when⦠| Use UDP when⦠|
|---|---|
| Every byte must arrive, in order (HTTP, DBs, file transfer) | Fresh data beats late data (voice, video, game state) |
| You want the OS to handle loss & ordering | You'll build your own reliability (QUIC, DNS, custom protocols) |
| Long-lived, high-throughput streams | Tiny request/response or multicast |