TCP vs UDP: Handshakes, Reliability & Sockets
The TCP three-way handshake, reliability and flow control, when UDP is the better choice, and socket programming in Python.
Two transport protocols
The transport layer delivers data between processes (identified by ports) on two hosts. The internet's two classic transport protocols take opposite approaches:
| TCP | UDP | |
|---|---|---|
| Connection | Connection-oriented (handshake first) | Connectionless |
| Reliability | Retransmits lost data, guarantees delivery or an error | No guarantees — packets may be lost |
| Ordering | Delivers bytes in order | Datagrams may arrive out of order |
| Data model | Byte stream | Individual datagrams (messages) |
| Flow/congestion control | Yes | No |
| Header size | 20+ bytes | 8 bytes |
| Speed / latency | More overhead | Minimal overhead |
| Used by | HTTP/1.1, HTTP/2, SSH, databases, email | DNS, video calls, online games, VoIP, QUIC (HTTP/3) |
The TCP three-way handshake
Before sending data, TCP establishes a connection and agrees on starting sequence numbers:
Client Server
│ ── SYN (seq = x) ─────────────────────▶ │ "I want to connect"
│ ◀──────────── SYN-ACK (seq = y, ack = x+1) │ "OK, I'm ready too"
│ ── ACK (ack = y+1) ───────────────────▶ │ "Great, let's talk"
│ ═════════════ data flows ═════════════ │
The handshake costs one round trip before any data is sent — which is why connection reuse (HTTP keep-alive, connection pools) matters so much for performance. With TLS on top, setup takes another 1 RTT (TLS 1.3).
Closing a connection uses FIN and ACK messages from each side. The side that closes first enters TIME_WAIT for a while, so busy servers making many short outbound connections can run out of ports.
How TCP makes delivery reliable
- Sequence numbers — every byte is numbered so the receiver can reorder and detect gaps.
- Acknowledgements (ACKs) — the receiver confirms what it got.
- Retransmission — data not acknowledged within a timeout is sent again.
- Checksums — detect corrupted segments.
- Flow control — the receiver advertises a window of how much it can accept, so a fast sender doesn't overwhelm a slow receiver.
- Congestion control — algorithms such as CUBIC and BBR slow down when the network is congested ("slow start", then grow carefully).
A side effect: head-of-line blocking. If one segment is lost, later bytes wait in the buffer until it's retransmitted — even if they belong to unrelated requests. HTTP/3 moves to QUIC over UDP partly to avoid this.
When UDP is the better choice
UDP just sends datagrams. That's ideal when:
- Late data is useless — in a video call, a retransmitted frame from 300 ms ago is worse than skipping it.
- Messages are tiny and independent — a DNS query fits in one datagram; a handshake would double the latency.
- You want to build your own reliability — QUIC implements streams, encryption and retransmission on top of UDP.
- Broadcast or multicast is needed (e.g. local service discovery).
Sockets: TCP in Python
A socket is the programming interface to the transport layer. Here's a TCP echo server and client on your own machine:
import socket
import threading
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # SOCK_STREAM = TCP
server.bind(("127.0.0.1", 0)) # port 0 = pick any free port
server.listen()
port = server.getsockname()[1]
def serve_one():
conn, addr = server.accept() # completes the three-way handshake
with conn:
data = conn.recv(1024)
conn.sendall(b"echo: " + data)
threading.Thread(target=serve_one).start()
with socket.create_connection(("127.0.0.1", port)) as client:
client.sendall(b"hello over TCP")
print(client.recv(1024).decode())
server.close()
Output:
echo: hello over TCP
Note that TCP is a byte stream: one sendall isn't guaranteed to arrive as one recv. Real protocols add framing — a length prefix or a delimiter such as \r\n (HTTP/1.1 headers) — so the receiver knows where messages end.
Sockets: UDP in Python
UDP has no listen/accept — you just send datagrams to an address:
import socket
receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # SOCK_DGRAM = UDP
receiver.bind(("127.0.0.1", 0))
address = receiver.getsockname()
sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for i in range(3):
sender.sendto(f"reading #{i}".encode(), address) # no handshake, no connection
for _ in range(3):
data, source = receiver.recvfrom(1024) # each recvfrom = one whole datagram
print(data.decode())
sender.close(); receiver.close()
Output:
reading #0
reading #1
reading #2
On localhost nothing gets lost. Across a real network, some datagrams could vanish or arrive out of order, and the application would have to cope.
Common ports to know
| Port | Service |
|---|---|
| 22 | SSH |
| 53 | DNS (UDP, and TCP for large responses) |
| 80 / 443 | HTTP / HTTPS (443 also UDP for HTTP/3) |
| 3306 / 5432 | MySQL / PostgreSQL |
| 6379 | Redis |
| 9092 | Kafka |
Common mistakes
- Assuming one
sendequals onerecvin TCP — always frame messages. - Opening a new connection per request — reuse connections and pools.
- Saying UDP is "unreliable so never use it" — HTTP/3, DNS and real-time media depend on it.
- No timeouts on sockets — a silent peer can hang your worker forever.
Key takeaways
- TCP: connection-oriented, reliable, ordered byte stream with flow and congestion control.
- UDP: connectionless datagrams with minimal overhead and no delivery guarantees.
- The three-way handshake (SYN, SYN-ACK, ACK) costs one round trip — reuse connections.
- Choose UDP when timeliness beats completeness, or when building a custom protocol like QUIC.