Chapter 5: UDP Programming

Updated

October 31, 2026

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

  1. 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.
  2. Online Multiplayer Gaming: Player movement and state updates require minimum latency; obsolete state updates can be safely discarded.
  3. DNS Queries: Simple request-and-reply transactions. If an answer doesn’t arrive within 1 second, the client simply re-queries.
  4. Metrics & Telemetry (StatsD, Prometheus push agents): High-frequency metrics where losing 0.1% of samples does not impair system observability.
  5. 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.go
package main

import (
    "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.go
package main

import (
    "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 size
buf := 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.

Sender                                            Receiver
  │  Packet [Seq: 1, Payload: "Data A"]              │
  ├─────────────────────────────────────────────────►│
  │  ACK [AckSeq: 1]                                 │
  │◄─────────────────────────────────────────────────┤
  │  Packet [Seq: 2, Payload: "Data B"] (Dropped)    │
  ├─── ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ✘│
  │  (Timer expires after 500ms)                     │
  │  Retransmit Packet [Seq: 2, Payload: "Data B"]   │
  ├─────────────────────────────────────────────────►│
  │  ACK [AckSeq: 2]                                 │
  │◄─────────────────────────────────────────────────┤

Minimal Reliable Stop-and-Wait Protocol

package main

import (
    "bytes"
    "encoding/binary"
    "errors"
    "net"
    "time"
)

type ReliablePacket struct {
    SeqNum uint32
    Data   []byte
}

func SendWithRetry(conn *net.UDPConn, pkt ReliablePacket, maxRetries int, timeout time.Duration) error {
    buf := new(bytes.Buffer)
    _ = binary.Write(buf, binary.BigEndian, pkt.SeqNum)
    buf.Write(pkt.Data)
    wireBytes := buf.Bytes()

    ackBuf := make([]byte, 4)

    for attempt := 0; attempt < maxRetries; attempt++ {
        if _, err := conn.Write(wireBytes); err != nil {
            return err
        }

        _ = conn.SetReadDeadline(time.Now().Add(timeout))
        n, err := conn.Read(ackBuf)
        if err == nil && n == 4 {
            ackSeq := binary.BigEndian.Uint32(ackBuf)
            if ackSeq == pkt.SeqNum {
                return nil // Successfully delivered and acknowledged!
            }
        }
        // If timeout occurred, loop retries transmission
    }
    return errors.New("packet delivery failed after maximum retries")
}

Multicast and Broadcast with UDP

1. UDP Broadcast (Local Subnet Only)

Broadcast transmits a packet to all hosts on the local network segment (255.255.255.255 or subnet broadcast address):

// Enabling broadcast on UDP socket:
destAddr, _ := net.ResolveUDPAddr("udp4", "255.255.255.255:9999")
conn, _ := net.DialUDP("udp4", nil, destAddr)
_, _ = conn.Write([]byte("Discovery beacon"))

2. UDP Multicast (Targeted Groups)

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 interfaces
conn, 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.