
In the previous article, we spent considerable time dissecting TCP — the three-way handshake for connection establishment, sequence numbers and ACKs for ordering and loss detection, sliding windows for flow control, and congestion control for probing the network. TCP builds a reliable byte-stream transport on top of the unreliable IP layer.
But you may have noticed something curious: not all data on the internet travels over TCP.
Open your browser's DevTools, and you'll see the vast majority of HTTP requests go over TCP. But if you capture the DNS queries your system makes, or inspect the traffic from a video call, you'll find those follow a different set of rules — no three-way handshake, no acknowledgments, no concept of a "connection" at all.
That set of rules is UDP (User Datagram Protocol). It's TCP's sibling at the transport layer, with a diametrically opposed design philosophy. This chapter explains why "unreliable" is sometimes the better choice.
Imagine you're on a video call. You and a friend are each at home, and data packets need to travel from your camera to your friend's screen in real time.
What happens if this video call goes over TCP?
When TCP detects a lost packet, it pauses delivery of all subsequent data, triggers a retransmission, and waits until the missing packet arrives before continuing. On your screen, this means the video freezes — not a momentary glitch, but a complete stall until that one lost packet is recovered. This could take hundreds of milliseconds. For a real-time conversation, that's unacceptable.
With UDP, the experience is different: a lost packet is simply gone. The receiver doesn't wait for it — it continues playing whatever arrives next. You might see a split-second artifact or blur on screen, but the call doesn't freeze. For real-time scenarios, timeliness matters far more than perfection.
Now consider another scenario: your browser queries a DNS server for github.com's IP address. This is typically a small packet — maybe 50 bytes. No connection needed, no flow control needed. If this went over TCP, you'd first perform a three-way handshake (1.5 RTT), then send the query (0.5 RTT), then tear down with a four-way handshake. The connection overhead alone would dwarf the actual data transfer.
These two scenarios reveal the same principle: TCP's guarantees aren't always necessary. Sometimes, their cost outweighs their benefit.
To understand why UDP exists, don't start with what UDP provides — start with what TCP adds on top of IP. TCP offers four key capabilities beyond IP's best-effort delivery:
| TCP Capability | Cost |
|---|---|
| Connection establishment (three-way handshake) | 1.5 RTT latency; state must be maintained per connection |
| Reliable delivery (ACK + retransmission) | Head-of-line blocking (subsequent data waits behind lost packets) |
| Flow control (sliding window) | Sender rate throttled by receiver, even if the network is idle |
| Congestion control (slow start + congestion avoidance) | Cannot fully utilize bandwidth early in the connection |
These capabilities are essential for file transfers, web pages, and API calls. But for real-time audio/video, online gaming, and DNS queries, latency matters more than reliability. A useful analogy:
The IP layer provides only "best-effort" packet delivery. TCP builds a second floor of "reliability" on top of it. But some applications don't want to live on that floor — they don't need everything TCP provides, or more precisely, they'd rather choose which guarantees to add themselves.
This is UDP's role: UDP is the thinnest possible wrapper around IP, handed to the application layer. It adds almost nothing, does minimal encapsulation, and lets the application directly control when to send, what to retransmit, and how to respond to congestion.
UDP's definition is remarkably concise. RFC 768 (1980) is only three pages long — in contrast, TCP's RFC 793 spans 85 pages.
UDP provides a connectionless, unreliable datagram service. Let's break that down:
Connectionless: The sender doesn't establish a connection with the receiver. The application hands UDP a datagram and a destination address; UDP encapsulates it and hands it to the IP layer for delivery. The receiver either gets it or doesn't — there's no "connection state" to maintain.
Unreliable: UDP does not guarantee delivery, ordering, or deduplication. None of these are UDP's responsibility.
Datagram Boundary Preserved: This is an often-overlooked but crucial difference between UDP and TCP.
TCP is a byte stream — when you call write on a socket, the data doesn't go directly onto the wire. Instead, it lands in the kernel's send buffer. The TCP stack decides when and at what granularity to package buffered data into TCP segments:
write("A"), write("B"), write("C") calls may be merged into a single TCP segment containing "ABC".write exceeding MSS gets split into multiple segments.The crucial insight: the sender's write calls and the receiver's read calls are completely unaligned. The receiver sees a continuous river of bytes with no markers indicating "this was originally the n-th write boundary." If the application needs to distinguish messages, it must add its own delimiters (e.g., \n) or length prefixes to "frame" messages within the byte stream.
Sender: Receiver:
write("Hello") ┐
write("World") ├──→ TCP byte stream ──→ read() → "HelloWorld" (one read)
write("!") ┘ No idea there were three messagesUDP, by contrast, preserves datagram boundaries — each sendto() produces an independent datagram. Three sendto calls mean the receiver must make three recvfrom calls, with each datagram arriving intact, never merged, never split. The boundary is part of the protocol itself; the application layer never needs to do its own framing.
Sender: Receiver:
sendto("Hello") → Datagram 1 → recvfrom() → "Hello" (clear boundary)
sendto("World") → Datagram 2 → recvfrom() → "World" (never merged)
sendto("!") → Datagram 3 → recvfrom() → "!" (never split)One-line summary: TCP hides datagram boundaries from the application; UDP exposes them.
Stateless: The server doesn't need to maintain any state for each "client." A UDP server receives a datagram, processes it, sends a reply, and forgets about it. This allows UDP servers to handle massive numbers of concurrent requests easily — no need to allocate per-connection buffers or track sequence numbers.
A simple analogy:
TCP: A phone call
→ Dial (connect) → Talk (bidirectional stream) → Hang up (teardown)
→ No answer? Busy tone. Bad signal? "Can you repeat that?"
UDP: Sending a text message
→ Compose → Send → Forget
→ Phone off? You don't know. Message lost in transit? You don't know.
→ But it's fast, and you can text many people at once.The UDP header is one of the simplest in the TCP/IP protocol suite. Eight bytes total, four fields:
0 7 8 15 16 23 24 31
┌────────┬────────┬────────┬────────┐
│ Source Port │ Destination Port │
│ (16 bits) │ (16 bits) │
├────────┴────────┼────────┴────────┤
│ Length │ Checksum │
│ (16 bits) │ (16 bits) │
├───────────────────┼───────────────────┤
│ │
│ Data (Payload) │
│ │
└───────────────────────────────────────┘Compare this with TCP's header. A TCP header is at least 20 bytes (without options) and contains sequence numbers, acknowledgment numbers, window size, flags (SYN/ACK/FIN/RST), urgent pointer, and more. UDP's header is just 40% the size of TCP's minimum header. The size difference reflects the philosophical gap — UDP trusts the application layer to know what it needs; the transport layer provides the absolute minimum encapsulation.
UDP isn't entirely devoid of reliability — it has a checksum field to detect transmission errors.
UDP's checksum calculation introduces a pseudo-header containing information from the IP layer:
Pseudo-header (12 bytes, not actually transmitted, used only for checksum calculation):
┌───────────────────────────────────────┐
│ Source IP address (4 bytes) │
│ Destination IP address (4 bytes) │
│ Reserved (1 byte, zero) │
│ Protocol (1 byte, UDP = 17) │
│ UDP Length (2 bytes) │
├───────────────────────────────────────┤
│ UDP Header (8 bytes) │
│ UDP Data │
└───────────────────────────────────────┘First, a common misconception: TCP also uses a pseudo-header, with the exact same design (RFC 793). This isn't a UDP-specific quirk — it's a universal transport-layer design in TCP/IP.
The core problem: UDP (and TCP) headers contain no IP addresses. If the IP layer mistakenly delivers a datagram to the wrong host, the transport layer cannot detect this error from port numbers alone.
You might wonder — doesn't the IP layer already guarantee "this packet is for me" before handing it up? It does, but IP-layer verification has three weak points:
① IPv4 header checksum is weak and recomputed at every hop. The IPv4 header checksum is only 16 bits and uses a simple one's complement sum. More importantly, every router modifies the TTL (decrements by 1) and recomputes the header checksum. A packet traversing 15 hops has its checksum recalculated 15 times — each recalculation is an opportunity for error. Router bugs, memory bit flips, or NAT devices miscalculating the checksum after rewriting addresses can all lead to "the IP header address is corrupted, but the checksum happens to match."
② IPv6 has no IP header checksum at all. IPv6 designers considered the link-layer CRC sufficient and removed the IP header checksum entirely. But the link-layer CRC only guarantees data integrity within a single hop — at the next router, the frame is torn down and rebuilt with a fresh CRC. If a router has an internal memory error that corrupts the destination IP address, nothing will detect it.
③ NAT and software bugs. NAT devices rewrite source and destination IPs and recompute the header checksum. If the NAT implementation has a bug — rewriting the address but miscalculating the checksum, or worse, computing a "correct" checksum for a wrong address — the transport-layer pseudo-header is the last line of defense.
The underlying design philosophy is defense in depth: each layer independently verifies its own data, never trusting the layer below to be perfect. The IP layer's checksum is best-effort; the transport layer uses a pseudo-header for an additional end-to-end address verification.
The pseudo-header is never actually transmitted — it's constructed on-the-fly during checksum computation. The sender extracts the source and destination IPs from its own IP header to build the pseudo-header; the receiver extracts the same fields from the received IP header and rebuilds the pseudo-header independently. If the IP addresses don't match, the checksums won't match, and the datagram is silently discarded.
The checksum is computed by taking the one's complement sum of all 16-bit words and then taking the one's complement of the result. The receiver recalculates it — if it doesn't match the received checksum, the datagram is silently discarded.
But here's the key: UDP discards corrupted datagrams without notifying the sender, let alone retransmitting. The checksum can only detect problems, not fix them. Fixing is up to the application layer.
One more nuance: in IPv4, the checksum can be set to 0, meaning "skip verification." This is sometimes used in latency-critical scenarios like VoIP — rather than dropping a frame due to a checksum error, let the application decide whether slightly corrupted data is usable.
Let's put TCP and UDP side by side to build intuition:
| Dimension | TCP | UDP |
|---|---|---|
| Connection model | Connection-oriented (three-way handshake) | Connectionless (send immediately) |
| Transmission unit | Byte stream (no boundaries) | Datagram (boundaries preserved) |
| Reliability | ACK + retransmission + sequence-based ordering | None (checksum only detects errors) |
| Ordering | Guaranteed in-order delivery | Not guaranteed (later packets may arrive first) |
| Flow control | Sliding window | None |
| Congestion control | Slow start + congestion avoidance + fast recovery | None (application can implement its own) |
| Header size | 20–60 bytes | 8 bytes |
| State maintenance | Per-connection state (sequence numbers, windows, etc.) | Stateless |
| Broadcast/multicast | Not supported | Supported |
| Typical use cases | HTTP, file transfer, email, SSH | DNS, video calls, online gaming, DHCP |
One crucial insight: UDP isn't a "simplified TCP"; it's intentionally minimal. Minimal means flexible — the application layer can add exactly the reliability features it needs, no more, no less.
When you type github.com and press Enter, the first thing your browser does is query a DNS server for the domain's IP address. A typical DNS request is a single packet of maybe 50 bytes, with an equally small response.
Going over TCP: three-way handshake (1.5 RTT) + query (0.5 RTT) + four-way teardown — the connection overhead alone is several times the actual data transfer. Going over UDP: one round trip, done.
Of course, DNS doesn't exclusively use UDP. When responses exceed 512 bytes (the traditional UDP safety limit), or for zone transfers, DNS falls back to TCP. But everyday queries are almost entirely UDP.
Returning to the video call scenario from the introduction. A video encoder produces dozens of frames per second, each encapsulated in a UDP datagram.
Lost frame #17? No problem — the player can hold frame #16 for a few tens of milliseconds, barely perceptible to the human eye. But if TCP waited to retransmit frame #17, the entire playback pipeline would be blocked, freezing the picture for 300ms. That's a terrible experience.
The golden rule of real-time media: drop frames if you must, but never freeze.
In a first-person shooter, your position and shooting actions need to be sent to the server dozens of times per second. If a single "moved forward one step" packet is lost, should TCP retransmit it? No — because by the time the retransmission arrives, you've already taken two more steps. That old position data is worthless.
Game servers typically send player state snapshots over UDP. Missed one snapshot? No worries — another one is coming in 16ms.
When you connect to Wi-Fi, your computer needs an IP address, subnet mask, gateway address, and DNS server addresses — all provided by DHCP. But here's the chicken-and-egg problem: before you get an IP address, you don't have an IP address.
DHCP uses UDP with broadcast to solve this. The DHCP client sends a UDP broadcast to 255.255.255.255:67, with source IP set to 0.0.0.0 ("I don't have an IP yet"). The DHCP server receives it and replies via UDP, assigning the client an IP.
TCP cannot do this — TCP requires a connection to be established first, and establishing a connection requires both sides to have IP addresses. This is why DHCP must use UDP.
This is a counterintuitive case. Google's QUIC protocol (the transport foundation of HTTP/3) chose UDP as its substrate — despite QUIC itself providing reliable delivery, complete with ACKs, retransmission, and congestion control, much like TCP.
Why not just use TCP directly? Because TCP is implemented in the operating system kernel, and iterating on it is painfully slow. Changing TCP's congestion control algorithm requires a kernel upgrade — which could take years to reach the majority of user devices.
QUIC's strategy: move the transport protocol to userspace. Use UDP as the "carrier," and implement all reliability mechanisms in the application layer. This lets Google ship new algorithms instantly, without waiting for OS vendors.
More importantly, QUIC solves TCP's head-of-line blocking problem. In TCP, if one packet is lost, all subsequent packets are held in the buffer. In QUIC, different streams are independent — a lost packet on Stream A does not block data delivery on Stream B.
QUIC is the ultimate vindication of UDP's design philosophy: UDP isn't "primitive"; it's "minimal." It hands the decisions to the layer that knows its own needs best — the application layer.
After seeing QUIC, you might wonder: can every application build its own reliability on UDP?
Yes, you can, but you'll need to implement:
These are exactly the capabilities TCP provides. Reimplementing them on UDP is essentially reinventing TCP — with the benefit of making different design choices (like QUIC's per-stream independence), and the cost of implementation complexity and bug risk.
A practical suggestion: unless you have a very specific reason (like QUIC's deployability advantage), just use TCP. Most applications don't need — and shouldn't attempt — to build reliability from scratch on UDP.
Observe DNS traffic over UDP
# Capture DNS (UDP port 53) packets
sudo tcpdump -i en0 'udp port 53' -n -v
# In another terminal, make a DNS query
nslookup github.comYou should see: one UDP datagram going out (query), one UDP datagram coming back (response). No handshake, no teardown — one question, one answer, clean and fast.
Check your system's UDP sockets
# macOS
netstat -an -p udp | head -20
# Linux
ss -u -aWhich processes are listening on UDP ports? DNS is typically on *.53; DHCP clients are typically on *.68.
Feel the header size difference between TCP and UDP
Capture both TCP and UDP packets to see the difference:
sudo tcpdump -i en0 -n -c 5 'tcp port 443' -v # HTTPS over TCP
sudo tcpdump -i en0 -n -c 5 'udp port 53' -v # DNS over UDPWrite a UDP echo server and client in Node.js
// server.mjs
import dgram from 'node:dgram';
const server = dgram.createSocket('udp4');
server.on('message', (msg, rinfo) => {
console.log(`${rinfo.address}:${rinfo.port} → ${msg.toString()}`);
// echo it back
server.send(msg, rinfo.port, rinfo.address);
});
server.bind(41234, () => {
console.log('UDP echo server listening on :41234');
});// client.mjs
import dgram from 'node:dgram';
const client = dgram.createSocket('udp4');
const msg = Buffer.from('Hello UDP!');
const start = Date.now();
client.send(msg, 41234, '127.0.0.1', (err) => {
if (err) console.error(err);
});
client.on('message', (msg) => {
console.log(`Echo: ${msg.toString()} (RTT: ${Date.now() - start}ms)`);
client.close();
});
// If no response within 2 seconds, assume it's lost
setTimeout(() => {
console.log('No response (packet may be lost)');
client.close();
}, 2000);Run node server.mjs first, then node client.mjs.
Now try: don't start the server, just run the client. What happens? The client sends the datagram and... nothing comes back. This is UDP: you send, but you don't know if it arrived.
Compare TCP echo and UDP echo performance
Compare the UDP echo above with a TCP echo implementation. Send 1000 "pings" and measure average RTT. The UDP version should be noticeably faster — no connection setup overhead, no kernel-level ACK and retransmission.
Try simulating packet loss: add if (Math.random() < 0.3) return; inside the server's message handler to drop 30% of messages. The UDP client needs to implement its own timeout and retransmission at the application layer — a hands-on taste of "building reliability on UDP."
This chapter completes your understanding of the transport layer's other half — UDP's connectionless design and its use cases. You now know both extremes: TCP's full reliability guarantees and UDP's minimal encapsulation.
But we're not done with TCP yet. In the TCP article, we covered Reno/CUBIC congestion control in detail — but those algorithms have a fundamental flaw: they wait for packet loss before slowing down, which means by the time they react, latency has already deteriorated. Is there a better way?
The next article dives into BBR — Google's groundbreaking congestion control algorithm that doesn't use packet loss at all. Instead, it directly measures the path's physical limits — bottleneck bandwidth and minimum RTT — to keep data flowing at exactly the right rate.