TCP vs UDP
On this page
- The Transport Layer Dilemma
- TCP (Transmission Control Protocol)
- UDP (User Datagram Protocol)
- Side-by-Side
- A UDP Client and Server in Python
- The Byte-Stream Trap in TCP
- Datagram Size and Fragmentation
- Head-of-Line Blocking
- Reliability Is Not Free
- The Rise of QUIC
- Debugging and Testing Each Protocol
- Choosing Between Them
- Practice
The Transport Layer Dilemma#
IP delivers packets on a best-effort basis: they may arrive out of order, duplicated, or not at all, and IP will not tell anyone. The transport layer sits on top and offers two very different deals. TCP gives applications a reliable, ordered byte stream at the cost of latency and complexity. UDP gives them almost raw access to IP, adding only ports and a checksum. Choosing between them, or understanding why a protocol chose, explains a great deal about how real systems behave.
TCP (Transmission Control Protocol)#
TCP is like a courier who numbers every page and insists on a signature for each one.
- Connection-oriented: a three-way handshake (SYN, SYN-ACK, ACK) sets up state on both ends before any data moves. See TCP Handshake & Control Flags.
- Reliable: every byte is acknowledged; missing data is retransmitted.
- Ordered: bytes are delivered to the application in exactly the order they were sent.
- Flow control: the receiver's advertised window stops a fast sender from overwhelming it.
- Congestion control: the sender probes network capacity and backs off on loss, sharing links fairly.
Typical users: HTTP/1.1 and HTTP/2, TLS, SSH, SMTP, database protocols, file transfers: anything where every byte must arrive intact.
UDP (User Datagram Protocol)#
UDP is like posting individual postcards: each one is independent, cheap, and may never arrive.
- Connectionless: no handshake; the first packet can carry data.
- No delivery guarantee: no acknowledgments, no retransmission.
- No ordering: datagrams arrive in whatever order the network delivers them.
- Message-oriented: each send is one datagram and each receive returns exactly one whole datagram.
- Tiny header: 8 bytes (source port, destination port, length, checksum) versus TCP's 20-60.
Typical users: DNS, DHCP, NTP, VoIP and video calls, online games, streaming telemetry, and QUIC (HTTP/3).
The reason real-time media prefers UDP: if a packet with 20 ms of audio is lost, TCP would stall the whole call while it retransmits, delivering stale audio hundreds of milliseconds later. The listener is better served by skipping the gap and playing the next packet on time.
Side-by-Side#
| TCP | UDP | |
|---|---|---|
| Setup before data | 1 round trip (plus TLS) | none |
| Delivery | guaranteed or connection fails | best effort |
| Ordering | strict | none |
| Data model | byte stream, no message boundaries | discrete datagrams, boundaries preserved |
| Header | 20-60 bytes | 8 bytes |
| Congestion control | built in | the application's responsibility |
| NAT/firewall friendliness | high; state tracked by handshake | lower; short idle timeouts, often blocked |
| Broadcast / multicast | no | yes |
A UDP Client and Server in Python#
import socket, threading
def udp_echo_server(sock):
while True:
data, addr = sock.recvfrom(2048) # one call = exactly one datagram
if data == b"stop":
break
sock.sendto(data.upper(), addr)
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(("127.0.0.1", 0)) # port 0: OS picks a free port
port = server.getsockname()[1]
threading.Thread(target=udp_echo_server, args=(server,), daemon=True).start()
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.settimeout(1.0) # UDP will never tell you a packet was lost
for msg in [b"hello", b"two", b"three"]:
client.sendto(msg, ("127.0.0.1", port)) # no handshake, no connection
for _ in range(3):
print(client.recvfrom(2048))
client.sendto(b"stop", ("127.0.0.1", port))
(b'HELLO', ('127.0.0.1', 49893))
(b'TWO', ('127.0.0.1', 49893))
(b'THREE', ('127.0.0.1', 49893))
The timeout is not optional. If a reply is lost, recvfrom would otherwise block forever, and on a real network you must decide yourself when to retry, exactly as a DNS stub resolver does. Also note that sendto succeeds even if nobody is listening: an ICMP "port unreachable" may come back and surface as an error on a later call (as ConnectionRefusedError on Linux with a connected socket, or ConnectionResetError on Windows), or it may never arrive at all.
The Byte-Stream Trap in TCP#
TCP delivers bytes, not messages. Three separate send calls can arrive as one recv, or one send can arrive split across several:
import socket
a, b = socket.socketpair() # a connected pair of stream sockets
for msg in [b"hello", b"two", b"three"]:
a.sendall(msg)
print("naive recv:", b.recv(1024))
naive recv: b'hellotwothree'
This is one of the most common bugs in hand-written network code: it passes tests on localhost and fails under real latency. Every protocol over TCP needs framing. HTTP uses headers plus Content-Length or chunking; many binary protocols use a length prefix:
import struct
def send_msg(sock, payload: bytes):
sock.sendall(struct.pack("!I", len(payload)) + payload) # 4-byte big-endian length
def recv_exact(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("peer closed mid-message")
buf += chunk
return buf
def recv_msg(sock):
(length,) = struct.unpack("!I", recv_exact(sock, 4))
return recv_exact(sock, length)
With these helpers, the same three sends come back as [b'hello', b'two', b'three']. UDP never needs this, because the datagram is the message; but a datagram that does not fit in one receive buffer is truncated, and the excess is discarded.
Datagram Size and Fragmentation#
A UDP datagram can in theory carry up to 65,507 bytes of payload over IPv4, but anything larger than the path MTU (typically 1500 bytes minus 28 for IP and UDP headers) gets fragmented by IP. If any fragment is lost, the whole datagram is lost, and many firewalls drop fragments outright. Well-behaved UDP protocols keep datagrams small: QUIC requires paths to support 1200-byte datagrams and sizes packets accordingly, and DNS falls back to TCP when a response is too large for its UDP buffer.
Head-of-Line Blocking#
TCP's strict ordering has a cost. When one segment is lost, every later segment that has already arrived waits in the receive buffer until the retransmission fills the gap. On a lossy link, this head-of-line blocking can make a reliable stream far slower than its bandwidth suggests. HTTP/2 multiplexes many requests over one TCP connection, so a single lost packet stalls all of them at once. UDP datagrams are independent; losing one has no effect on the others.
Reliability Is Not Free#
Each TCP guarantee costs latency: the handshake adds a round trip before data (plus one more for TLS 1.3), acknowledgments add packets, and congestion control starts slowly. Protocols built on UDP choose exactly the reliability they need:
- Sequence numbers to detect loss and reordering, without necessarily repairing them.
- Selective retransmission only for important data (a game resends "player died" but not an old position update).
- Forward error correction so receivers can reconstruct small losses without waiting.
- Jitter buffers in voice and video that trade a few milliseconds of delay for smooth playback.
The catch is that an application sending UDP without congestion control can flood a network, so serious UDP protocols implement their own.
The Rise of QUIC#
For decades, the web required TCP. QUIC, originally developed at Google and standardized by the IETF, runs over UDP and implements streams, reliability and congestion control in user space, with TLS 1.3 built in. HTTP/3 runs on top of it. Its advantages over TCP plus TLS:
- Combined handshake: transport and encryption are established in one round trip, and resumed connections can send data immediately (0-RTT).
- Independent streams: a lost packet stalls only the streams whose data it carried.
- Connection migration: connections are identified by connection IDs rather than the IP-and-port four-tuple, so a phone moving from Wi-Fi to mobile data can keep its connection.
- Encrypted transport headers: middleboxes cannot inspect or tamper with most transport metadata, which also makes the protocol easier to evolve.
Because some networks block or throttle UDP, browsers always keep TCP-based HTTP/2 or HTTP/1.1 as a fallback. See HTTP and HTTPS.
Debugging and Testing Each Protocol#
ss -tulpn # listening TCP and UDP sockets with owning process
nc -vz example.com 443 # TCP: a completed handshake proves the port is open
nc -u -v 192.0.2.53 53 # UDP: "succeeded" proves nothing; no reply may be normal
dig @192.0.2.53 example.com # test UDP services with a real protocol request instead
sudo tcpdump -n udp port 53 # watch queries and replies directly
Testing UDP reachability is inherently harder: with no handshake, a silent port and a filtered port look identical. Use a protocol-specific client (dig, an NTP query, the application itself) and watch for replies. Also remember that NATs and stateful firewalls expire UDP mappings much faster than TCP ones, so long-lived UDP flows need periodic keepalives (see NAT and Port Forwarding).
Choosing Between Them#
- Use TCP when correctness matters and a few extra milliseconds do not: web pages and APIs, file transfers, databases, SSH, message queues. It is the default for good reason, and middleboxes handle it well.
- Use UDP when timeliness beats completeness, when you need broadcast or multicast, or when you are implementing a transport with different trade-offs: real-time media, games, DNS, DHCP, service discovery, and QUIC.
- Use a higher-level option when it exists: WebSockets for bidirectional messages in the browser (over TCP), WebRTC data channels for peer-to-peer (over UDP), or QUIC libraries instead of inventing your own reliability.
In practice most systems use both: DNS lookups over UDP, the API call over TCP or QUIC, and a video stream that adapts to whatever the network allows.
Practice#
Modify the UDP example to drop every third datagram in the server (skip the sendto), then add sequence numbers and a retry loop to the client so every message is eventually echoed exactly once. You will have reimplemented, in miniature, the problem TCP solves for you.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.