Chapter 20 — UDP: fire and forget
In one sentence
UDP adds ports and a checksum to IP, and nothing else — no connection, no reliability, no ordering — which makes it fast and perfect for some jobs.
The entire UDP header
That's not a joke. This is all of it — 8 bytes:
+------------------+------------------+
| Source Port (2B) | Dest Port (2B) |
+------------------+------------------+
| Length (2B) | Checksum (2B) |
+------------------+------------------+
| your data... |
+-------------------------------------+
Compare with TCP's 20+ bytes and you see the philosophy: UDP gets out of the way.
What UDP does and doesn't do
| UDP does | UDP does NOT do |
|---|---|
| Say which program it's for (ports) | Establish a connection |
| Check the data isn't corrupted | Guarantee delivery |
| Preserve message boundaries | Guarantee order |
| Deliver immediately | Retransmit lost data |
| Control its sending rate |
Send one sendto() of 200 bytes → exactly one 200-byte datagram arrives (or
doesn't). No merging, no splitting. That's different from TCP and sometimes
exactly what you want.
When "unreliable" is the right answer
This surprises beginners: why would you ever choose a protocol that can lose your data?
| Use case | Why UDP wins |
|---|---|
| DNS lookups (Ch 18) | One tiny question, one tiny answer. Setting up a connection would triple the cost. Lost? Just ask again. |
| Voice and video calls | A voice packet that arrives 200 ms late is useless — you'd rather skip it than pause the call waiting for it. Retransmission actively hurts. |
| Online games | Only the latest position matters. Re-sending where the player was 300 ms ago is pointless. |
| DHCP (Ch 17) | You don't have an address yet — you can't do a TCP handshake. |
| NTP (time sync) | Tiny, frequent, and lateness would defeat the purpose. |
| Video streaming over QUIC / HTTP-3 | Wants reliability but implemented its own way, in the application (Ch 30). |
| VPNs (WireGuard, OpenVPN-UDP) | Avoids "TCP inside TCP", where two retransmission systems fight each other and collapse. |
The pattern: UDP is right when lateness is worse than loss, or when the message is so small that a connection isn't worth it.
What your app must do itself
If you use UDP and you do need reliability, you must build it: sequence numbers, acknowledgements, retransmission, ordering, and — importantly — rate control. An application that blasts UDP as fast as it can is a bad network citizen and will cause congestion for everyone.
Practice (20 min)
A. Send UDP by hand:
# Terminal 1: listen
nc -u -l 9999
# Terminal 2: send
nc -u 127.0.0.1 9999
# type lines; they appear in terminal 1
Notice: unlike the TCP version in Chapter 19, there's no connection. Kill terminal 1 and keep typing in terminal 2 — it doesn't complain. The data just goes nowhere. That's UDP.
B. See UDP in the wild. Your machine is doing this constantly:
sudo tcpdump -i any -n udp
Leave it a minute. You'll see DNS (port 53), mDNS (5353, .local discovery),
maybe NTP (123), DHCP (67/68), and QUIC (443/UDP — that's HTTP/3, Chapter 30).
C. Compare TCP and UDP DNS:
sudo tcpdump -i any -n 'port 53' &
dig example.com # uses UDP -- 2 packets
dig +tcp example.com # forces TCP -- handshake + query + teardown
Count the packets in each case. That's the cost of a connection, visible.
D. Check UDP loss statistics on your machine:
netstat -su | head -20 # look for "packet receive errors"
nstat -az | grep -i udp # Linux
Because UDP loss is silent, these counters are the only way to see it.
Build it in Go (40 min) — measure what UDP doesn't promise
The "What your app must do itself" list above is easier to remember once you've had to implement it. This lab is an echo server plus a client that stamps every datagram with a sequence number, then measures loss, reordering, duplication, RTT percentiles, and jitter. These are the exact metrics a video-call or game client tracks.
// udpping: a UDP echo server and a client that measures what UDP does NOT
// promise you: loss, reordering, duplication, and jitter.
//
// go run ./udpping -serve :9999
// go run ./udpping -to 127.0.0.1:9999 -n 200 -every 10ms
package main
import (
"encoding/binary"
"errors"
"flag"
"fmt"
"math"
"net"
"os"
"slices"
"syscall"
"time"
)
func main() {
serve := flag.String("serve", "", "run the echo server on this address")
to := flag.String("to", "", "server address to probe")
n := flag.Int("n", 100, "number of probes")
every := flag.Duration("every", 20*time.Millisecond, "interval between probes")
size := flag.Int("size", 64, "datagram size in bytes (try 1472 and 1473 with a 1500 MTU)")
flag.Parse()
switch {
case *serve != "":
server(*serve)
case *to != "":
client(*to, *n, *every, *size)
default:
flag.Usage()
os.Exit(2)
}
}
func server(addr string) {
pc, err := net.ListenPacket("udp", addr)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("echoing UDP on", pc.LocalAddr())
buf := make([]byte, 65535)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
continue
}
pc.WriteTo(buf[:n], from) // no connection, no state: reply to whoever sent it
}
}
func client(addr string, n int, every time.Duration, size int) {
conn, err := net.Dial("udp", addr) // "connected" UDP: the kernel filters replies for us
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer conn.Close()
if size < 16 {
size = 16
}
sent := make(map[uint64]time.Time, n)
var rtts []time.Duration
var lastSeq uint64
received, reordered, dups := 0, 0, 0
seen := make(map[uint64]bool, n)
// Receiver goroutine: every reply carries the sequence number we sent.
type reply struct {
seq uint64
at time.Time
}
replies := make(chan reply, n)
go func() {
buf := make([]byte, 65535)
warned := false
for {
m, err := conn.Read(buf)
if errors.Is(err, syscall.ECONNREFUSED) {
// UDP has no handshake, so how can it be "refused"? The server's
// kernel answered with ICMP "port unreachable" and ours relayed it.
if !warned {
fmt.Println("read: connection refused -> ICMP port unreachable: nothing listens there")
warned = true
}
continue
}
if err != nil {
return // socket closed
}
if m >= 8 {
replies <- reply{binary.BigEndian.Uint64(buf), time.Now()}
}
}
}()
payload := make([]byte, size)
tick := time.NewTicker(every)
defer tick.Stop()
for seq := uint64(1); seq <= uint64(n); seq++ {
<-tick.C
binary.BigEndian.PutUint64(payload, seq)
sent[seq] = time.Now()
if _, err := conn.Write(payload); err != nil {
fmt.Printf("seq %d: write error: %v\n", seq, err) // e.g. "message too long" past the MTU
}
drain:
for {
select {
case r := <-replies:
switch {
case seen[r.seq]:
dups++
case r.seq < lastSeq:
reordered++
fallthrough
default:
seen[r.seq] = true
received++
rtts = append(rtts, r.at.Sub(sent[r.seq]))
lastSeq = max(lastSeq, r.seq)
}
default:
break drain
}
}
}
time.Sleep(time.Second) // let stragglers arrive
for len(replies) > 0 {
r := <-replies
if !seen[r.seq] {
seen[r.seq] = true
received++
rtts = append(rtts, r.at.Sub(sent[r.seq]))
}
}
loss := 100 * float64(n-received) / float64(n)
fmt.Printf("%d sent, %d received, %.1f%% loss, %d reordered, %d duplicated\n",
n, received, loss, reordered, dups)
if len(rtts) == 0 {
return
}
slices.Sort(rtts)
var sum, jitter float64
for i, r := range rtts {
sum += float64(r)
if i > 0 {
jitter += math.Abs(float64(r - rtts[i-1]))
}
}
fmt.Printf("rtt min/p50/p99/max = %v / %v / %v / %v avg=%v\n",
rtts[0], rtts[len(rtts)/2], rtts[len(rtts)*99/100], rtts[len(rtts)-1],
time.Duration(sum/float64(len(rtts))))
}
$ go run ./udpping -serve 127.0.0.1:9999 &
$ go run ./udpping -to 127.0.0.1:9999 -n 100 -every 2ms
100 sent, 100 received, 0.0% loss, 0 reordered, 0 duplicated
rtt min/p50/p99/max = 52.792µs / 132.083µs / 1.360292ms / 1.360292ms avg=144.76µs
$ go run ./udpping -to 127.0.0.1:9998 -n 3 # nothing listening
read: connection refused -> ICMP port unreachable: nothing listens there
3 sent, 0 received, 100.0% loss, 0 reordered, 0 duplicated
$ go run ./udpping -to 127.0.0.1:9999 -n 3 -size 70000
seq 1: write error: ... write: message too long
What to notice:
- "Connection refused" on a protocol without connections. The server's
kernel answered with an ICMP port unreachable (Chapter 14). Because the
client socket is "connected", the kernel hands that error back to the
next
Read. An unconnected socket (ListenPacket) would never see it. message too long: a UDP datagram can't exceed 65,507 bytes of payload, and anything over the path MTU is fragmented at the IP layer (Chapter 15). Real UDP protocols (DNS, QUIC) keep datagrams around 1,200 bytes to avoid fragmentation completely.- One goroutine sends, another receives. UDP gives you no request/response pairing. The sequence number is your pairing, and the same is true of QUIC packet numbers and RTP in voice calls.
Exercises:
- (Linux) Add impairment and measure it:
sudo tc qdisc add dev lo root netem delay 20ms 5ms loss 2% reorder 10%, run the client against127.0.0.1, then remove it withsudo tc qdisc del dev lo root. Do your numbers match what you configured? - Find the largest
-sizethat works between two real machines on your LAN. It should be 1472 bytes (1500 MTU − 20 IP − 8 UDP) unless you add a don't-fragment option. - Add a retransmit: if a reply doesn't arrive within 3× the median RTT, resend it. Congratulations, you've started reinventing TCP. Stop and read Chapter 22.
Common confusions
- "UDP is unreliable so it's worse." It's simpler. For the right job it is strictly better. Choosing TCP for a voice call would make the call worse.
- "UDP is faster." Per-packet, yes — no handshake, no waiting for acknowledgements. But UDP has no congestion control, so on a congested network, a well-behaved TCP flow may actually deliver more usable data.
- "Nobody uses UDP for important things." HTTP/3 — a growing share of all web traffic — runs over UDP.
Check yourself
- What are the only two things UDP adds to IP?
- Why is UDP the right choice for a voice call?
- Why does DHCP have to use UDP?
- What must your application implement if it uses UDP but needs reliability?
Further reading
- Video: "TCP vs UDP" — Practical Networking or PowerCert. Almost every networking channel has one; they're all fine.
- Article: "What is UDP?" — Cloudflare Learning Center.
- Book: Kurose & Ross, Chapter 3 (Transport Layer) — the standard treatment.