TCP vs UDP
Almost everything that crosses a network rides on one of two transport protocols: TCP (Transmission Control Protocol) and UDP (User Datagram Protocol). TCP behaves like a phone call: you connect first, and the line makes sure everything you say arrives, in order. UDP behaves like posting postcards: you just send them, quickly, and nobody promises they arrive. Understanding this one choice explains why web pages, voice calls and online games work the way they do. It is also the foundation for the rest of this series: HTTP, WebSocket and MQTT build on this.
What the Transport Layer Does
The network layer (IP) can carry a packet from one computer to another, but that is all it does. It does not say which program on the computer should receive the packet, and it makes no promise that the packet arrives at all, arrives once, or arrives in order. The transport layer adds the missing pieces:
- Ports so that many programs on one computer can share a single IP address. Both TCP and UDP have them.
- Optional reliability (acknowledgements, ordering, retransmission). TCP provides it, UDP does not.
Ports
A port is a 16-bit number, so it ranges from 0 to 65535. The IP address finds the
computer; the port finds the program on it. Ports 0 to 1023 are the
well-known ports, reserved by convention for standard services. Your own servers usually
use a higher number, and a client program is typically given a temporary, high-numbered port by the
operating system when it connects.
| Port | Service | Notes |
|---|---|---|
22 | SSH | Secure remote login, runs over TCP |
53 | DNS | Name lookups. Usually UDP, with TCP used for larger answers |
80 | HTTP | Plain web traffic, runs over TCP |
443 | HTTPS | Encrypted web traffic. Typically TCP, and UDP when HTTP/3 is used |
1883 | MQTT | The default port for unencrypted MQTT, runs over TCP |
A connection is identified by both ends: the source IP address and port, and the
destination IP address and port. That is why you can open ten browser tabs to the same server on port
443 at once. Each tab uses a different source port on your side, so every connection is unique.
TCP: Connect First, Then Talk
TCP is connection-oriented. Before any data moves, the two computers set up a connection with a three-way handshake. Each side picks a starting sequence number and tells the other what it expects next:
- SYN. The client says "I want to talk, my numbering starts at x".
- SYN-ACK. The server says "OK, mine starts at y, and I expect your next number to be x+1".
- ACK. The client says "Got it, I expect y+1 next". The connection is now open.
The handshake costs one round trip before the first byte of real data can be sent. In return, TCP gives you a set of guarantees:
- Reliable delivery. Every byte is numbered. The receiver sends acknowledgements saying how far it has received, and the sender retransmits anything that is not acknowledged in time.
- In-order delivery. If segments arrive out of order, TCP holds them back and hands your program the bytes in the right order.
- Flow control. The receiver advertises a receive window, the amount of data it can still accept. A slow receiver can make a fast sender wait.
- Congestion control. The sender watches for signs of an overloaded network, such as lost segments, and slows down. A new connection typically starts cautiously and speeds up as long as things go well.
- Orderly closing. Each side sends a
FINwhen it has finished sending, and the other side acknowledges it. ARST(reset) is the abrupt alternative: it drops the connection at once. You see it, for example, when you try to connect to a port where nothing is listening, which programs report as "connection refused".
How TCP Recovers From Loss
Networks drop packets: a router is overloaded, a Wi-Fi signal is weak. TCP hides this from your program. The next figure follows three segments of data where the middle one is lost on the way.
UDP: Just Send It
UDP is connectionless. There is no handshake and no connection to close: your program hands the operating system a chunk of data and a destination address and port, and the system sends it as one packet called a datagram. That is the whole protocol. In return for the missing guarantees you get speed and simplicity:
- No handshake. The first datagram can go out immediately, with no waiting round trip.
- No retransmission. If a datagram is lost, nobody notices and nothing is resent, unless your own program does it.
- No ordering. Datagrams can arrive in a different order than they were sent, and occasionally a datagram can arrive twice.
- No flow or congestion control. UDP will send as fast as your program asks, which is a responsibility the application takes on.
- A tiny header. Only 8 bytes, compared with at least 20 for TCP.
Message boundaries: datagrams vs a byte stream
There is one more difference that trips up beginners. UDP keeps message boundaries: if you
send three datagrams, the receiver gets three separate datagrams, each exactly as it was sent (if they arrive).
TCP is a byte stream. It guarantees the bytes, not how they are grouped. If you send
hello and then world, the receiver may read helloworld in one go, or
hel and then loworld. If your protocol has messages, you must mark where they begin and
end yourself, for example with a newline or a length prefix (see Common Mistakes below).
The Headers Side by Side
Each TCP segment and UDP datagram starts with a small header placed in front of your data. The figure draws them the way specifications do, as rows that are 32 bits (4 bytes) wide. The TCP header carries all the machinery for reliability: sequence and acknowledgement numbers (orange), flags such as SYN, ACK and FIN (purple) and the receive window for flow control (green). UDP has none of it.
TCP vs UDP at a Glance
| Feature | TCP | UDP |
|---|---|---|
| Connection | Connection-oriented: a three-way handshake first | Connectionless: just send |
| Reliability | Acknowledgements and retransmission, so data arrives or the connection reports failure | None: datagrams may be lost or duplicated |
| Ordering | Bytes are delivered in order | No guarantee |
| Flow and congestion control | Yes | No |
| Overhead and speed | 20+ byte header, handshake, more state to keep. Slower to start | 8 byte header, no setup. Low delay |
| Message boundaries | None: a continuous byte stream | Kept: one send is one datagram |
| Typical uses | HTTP and HTTPS, SSH, SMTP (email), FTP, MQTT | DNS queries, DHCP, NTP (time), voice and video calls, live streaming, online games, CoAP |
Modern protocols do not always pick a side cleanly. QUIC, which carries HTTP/3, runs on top of UDP and builds its own reliability, ordering and encryption on top of it. That lets it avoid some of TCP's limits (a lost packet stalls only one stream instead of the whole connection) while still being reliable. Whenever you need reliability on UDP, you either use a protocol like this or write that logic yourself.
Which One Should I Use?
Two questions settle most cases:
A live voice call shows why UDP exists. If a fraction of a second of audio is lost, it is better to skip it than to stop the conversation and wait for a retransmission that would arrive too late to be useful. A bank transfer is the opposite: a single missing or reordered byte is unacceptable, so TCP is the right tool. Small devices can use both, too. For example, an ESP32 can open TCP and UDP sockets over Wi-Fi (see ESP32 Wi-Fi).
127.0.0.1 (localhost), so nothing leaves your machine.Try It Yourself
A TCP echo server and client
You need Python 3. Save the server as tcp_server.py and start it first:
import socket
HOST, PORT = "127.0.0.1", 5000
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
print("Waiting for a client on", HOST, PORT)
conn, addr = server.accept() # returns once a client has connected
with conn:
print("Connected by", addr)
while True:
data = conn.recv(1024)
if not data: # empty bytes means the client closed (FIN)
break
conn.sendall(data) # echo the same bytes back
In a second terminal, save and run the client as tcp_client.py:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("127.0.0.1", 5000)) # the three-way handshake happens here
s.sendall(b"hello")
print("Echo:", s.recv(1024))
The client prints Echo: b'hello' and the server prints the address and temporary port the client was given.
A UDP sender and receiver
Start the receiver (udp_receiver.py) first, and stop it with Ctrl+C:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind(("127.0.0.1", 5001))
print("Listening on UDP port 5001")
while True:
data, addr = sock.recvfrom(2048)
print(addr, data.decode())
Then run the sender (udp_sender.py):
import socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
for i in range(3):
sock.sendto(f"message {i}".encode(), ("127.0.0.1", 5001))
The receiver prints three lines, one per datagram. Now stop the receiver and run the sender again: it still finishes without any error, because nothing in UDP tells the sender that nobody is listening.
Watch the connections
While the TCP server is waiting, list the open ports. On Windows use netstat -an and look for LISTENING next to 127.0.0.1:5000. On Linux use ss -tuln, where t is TCP, u is UDP, l is listening and n shows numbers instead of names.
netstat -an # Windows
ss -tuln # Linux
To see the handshake itself, capture traffic on the loopback interface in Wireshark and enter this display filter to show only the first packet of each new TCP connection (SYN set, ACK not set):
tcp.flags.syn == 1 && tcp.flags.ack == 0
Clear the filter, or use tcp.port == 5000, to see the whole conversation: SYN, SYN-ACK, ACK, your data, and the FIN packets at the end.
Common Mistakes
-
Assuming one
send()equals onerecv()on TCP. TCP has no message boundaries, so onerecv()can return half a message or two messages joined together. Frame your messages: end each one with a newline, or send its length first. A length prefix looks like this:import struct def send_msg(sock, payload): sock.sendall(struct.pack("!I", len(payload)) + payload) def recv_exact(sock, n): buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("connection closed") buf += chunk return buf def recv_msg(sock): (length,) = struct.unpack("!I", recv_exact(sock, 4)) return recv_exact(sock, length) - Using UDP without handling loss, duplicates and reordering. If your data matters, add sequence numbers, acknowledgements and timeouts, or pick a protocol that already has them. Do not expect every datagram to arrive.
- Sending large UDP datagrams. A typical Ethernet link carries about 1500 bytes per packet including headers. A bigger datagram is split into fragments, and if any fragment is lost the whole datagram is lost. Keeping each payload to around 1200 bytes is a commonly used safe size.
- Forgetting that firewalls treat UDP differently. With no connection to follow, firewalls and NAT routers typically remember a UDP exchange only for a short time, and some networks block UDP altogether. A service that works over UDP at home may need a rule, or a TCP fallback, elsewhere.
- Blaming the wrong layer. "Connection refused" usually means nothing is listening on that port (a reset came back). A timeout usually means packets are being dropped on the way, often by a firewall.