Chapter 5: UDP Programming: When Connectionless Is Right
While TCP provides a reliable, ordered, full-duplex stream, that reliability comes at the cost of connection state, three-way handshakes, head-of-line blocking, and retransmission delays. When applications demand minimum latency, independent packet delivery, or one-to-many communication, the User Datagram Protocol (UDP) is the ideal tool.
In this chapter, you will learn when to choose UDP, how to write UDP servers and clients using Go’s net.PacketConn, how to mitigate packet loss and fragmentation, how to layer reliability on top of UDP, and how to implement broadcast and multicast.
Why Choose UDP Over TCP
UDP trades all guarantees of TCP for raw speed and simplicity:
Feature
TCP
UDP
Handshake
3-way (SYN, SYN-ACK, ACK)
None (immediate transmission)
Header Overhead
20–60 bytes
8 bytes
Delivery Guarantee
Yes (automatic retransmissions)
No (best effort)
Order Guarantee
Yes (strict sequence)
No (packets may arrive out of order)
Flow & Congestion Control
Yes (throttles on packet loss)
No (sender controls rate)
Framing
Byte stream (no message boundaries)
Datagrams (discrete message boundaries)
Communication Pattern
Point-to-point (unicast only)
Unicast, Broadcast, Multicast
Primary Use Cases for UDP
Real-Time Media & Voice (VoIP, Video Streaming): Dropping a frame of audio or video is far better than waiting 200ms for a retransmitted frame that is already obsolete.
Online Multiplayer Gaming: Player movement and state updates require minimum latency; obsolete state updates can be safely discarded.
DNS Queries: Simple request-and-reply transactions. If an answer doesn’t arrive within 1 second, the client simply re-queries.
Metrics & Telemetry (StatsD, Prometheus push agents): High-frequency metrics where losing 0.1% of samples does not impair system observability.
Modern Protocols (QUIC / HTTP/3): Building specialized reliability, encryption, and multiplexing in user space over UDP to eliminate head-of-line blocking.
Basic UDP Client and Server Patterns
Because UDP does not have persistent connections, servers do not use an accept loop. Instead, a single socket reads and writes independent datagrams to and from arbitrary addresses.
The UDP Server
// udp_server.gopackage mainimport("fmt""log""net")func main(){// Listen on UDP port 8888 pc, err := net.ListenPacket("udp",":8888")if err !=nil{ log.Fatalf("failed to bind UDP socket: %v", err)}defer pc.Close() log.Printf("UDP server listening on %s", pc.LocalAddr()) buf :=make([]byte,2048)for{// Read a datagram and obtain the sender's address n, clientAddr, err := pc.ReadFrom(buf)if err !=nil{ log.Printf("Read error: %v", err)continue} log.Printf("Received %d bytes from %s: %s", n, clientAddr,string(buf[:n]))// Send reply back to the sender reply :=[]byte(fmt.Sprintf("ACK: %s", buf[:n]))if _, err := pc.WriteTo(reply, clientAddr); err !=nil{ log.Printf("Write error to %s: %v", clientAddr, err)}}}
The UDP Client
// udp_client.gopackage mainimport("fmt""log""net""time")func main(){// Resolve remote server address serverAddr, err := net.ResolveUDPAddr("udp","127.0.0.1:8888")if err !=nil{ log.Fatal(err)}// Create an unbound local UDP socket conn, err := net.DialUDP("udp",nil, serverAddr)if err !=nil{ log.Fatal(err)}defer conn.Close()// Send message msg :=[]byte("ping from Go UDP client")if _, err := conn.Write(msg); err !=nil{ log.Fatal(err)}// Set read deadline to avoid blocking indefinitely if packet is dropped _ = conn.SetReadDeadline(time.Now().Add(2* time.Second)) replyBuf :=make([]byte,2048) n, _, err := conn.ReadFrom(replyBuf)if err !=nil{ log.Fatalf("Failed to receive reply: %v", err)} fmt.Printf("Server replied: %s\n",string(replyBuf[:n]))}
Handling Partial Reads and Packet Loss
1. Truncation and Buffer Sizing
In UDP, each ReadFrom call consumes exactly one whole datagram. If your buffer is smaller than the incoming datagram, the operating system kernel copies as many bytes as fit into your slice and discards the remainder permanently (on POSIX systems with MSG_TRUNC).
Always ensure your receive buffer is large enough for the Maximum Transmission Unit (MTU), typically 1500 bytes for standard Ethernet:
const MaxDatagramSize =65535// Maximum theoretical UDP payload sizebuf :=make([]byte,1500)// Typical safe internet MTU
2. Fragmentation and Path MTU
If a UDP datagram exceeds the link MTU (usually 1500 bytes on the Internet), the IP layer fragments it into multiple IP packets. If any single fragment is dropped, the entire UDP datagram is discarded by the receiver’s kernel!
[!TIP] Keep UDP payloads under 1472 bytes for IPv4 (\(1500 - 20\text{ (IPv4 header)} - 8\text{ (UDP header)}\)) or 1452 bytes for IPv6 to avoid IP fragmentation entirely.
Implementing Reliability on Top of UDP
When you need UDP’s multiplexing advantages but still require reliability, you can implement sequence numbers and acknowledgments in user space.
Multicast delivers packets to a subscription group identified by a multicast IP (IPv4 range 224.0.0.0 to 239.255.255.255). Go provides golang.org/x/net/ipv4 for low-level multicast group membership and interface binding.
// Subscribing to a multicast group using standard net:mcastAddr, err := net.ResolveUDPAddr("udp","224.0.0.1:9999")if err !=nil{ log.Fatal(err)}// ListenMulticastUDP binds and joins the specified multicast group on all interfacesconn, err := net.ListenMulticastUDP("udp",nil, mcastAddr)if err !=nil{ log.Fatal(err)}defer conn.Close()
Chapter Summary
UDP is connectionless and datagram-oriented, preserving message boundaries with minimal header overhead.
Servers use net.PacketConn and ReadFrom / WriteTo rather than the accept loops of TCP.
Payloads larger than the MTU lead to IP fragmentation; keep UDP packet payloads below 1472 bytes.
A receive buffer smaller than the datagram causes silent truncation of data.
Applications requiring both low latency and reliability can build sequence numbers and ACK timeouts over UDP.
In the next chapter, we will explore Unix Domain Sockets for ultra-fast local inter-process communication.