Frontend Labs
⋯

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:

  1. (Linux) Add impairment and measure it: sudo tc qdisc add dev lo root netem delay 20ms 5ms loss 2% reorder 10%, run the client against 127.0.0.1, then remove it with sudo tc qdisc del dev lo root. Do your numbers match what you configured?
  2. Find the largest -size that 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.
  3. 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

  1. What are the only two things UDP adds to IP?
  2. Why is UDP the right choice for a voice call?
  3. Why does DHCP have to use UDP?
  4. 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.

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36