Updated Jul 18, 2026

08 - UDP & Datagrams

📋 Jump to Takeaways

🎁 TCP promises that every byte arrives, in order, no matter what. That promise costs handshakes, acknowledgments, and retransmits. What if you're streaming live video or firing metrics at a server, where a lost packet matters less than getting the next one out fast?

UDP is the other transport protocol. It trades TCP's reliability for speed and simplicity: no connection, no ordering, no delivery guarantee, just discrete messages fired at an address. This lesson covers when that trade is worth it and how to write UDP servers and clients in Go.

The Connectionless Model

There is no handshake in UDP and no connection to establish. A handshake is the short back-and-forth TCP does before any data flows, where both sides confirm they're connected and ready. You send a datagram, a self-contained message, to an address, and it either arrives or it doesn't. Compare that to the TCP byte stream from the last lesson: TCP hands you an unbroken flow of bytes with no boundaries, while UDP preserves message boundaries exactly. One WriteTo becomes one datagram becomes one ReadFrom.

That single property, message boundaries, is why UDP feels so different to program. You never have to frame messages yourself, because each datagram is already a complete unit.

A UDP Server

Instead of listening and accepting connections, a UDP server opens a packet connection and reads datagrams as they arrive. net.ListenPacket gives you a net.PacketConn; ReadFrom returns the bytes and the sender's address so you know where to reply:

pc, err := net.ListenPacket("udp", ":9000")
if err != nil {
    log.Fatal(err)
}
defer pc.Close()

buf := make([]byte, 1024)
for {
    n, addr, err := pc.ReadFrom(buf)   // one datagram per read
    if err != nil {
        continue
    }
    pc.WriteTo(buf[:n], addr)          // echo it back to the sender
}

Notice there's no goroutine-per-connection here, because there are no connections. A single loop serves every client, replying to whichever address sent the last datagram.

A UDP Client

The simplest client dials the server and gets back a connected net.Conn it can Write to and Read from, with the destination baked in:

conn, err := net.Dial("udp", "localhost:9000")
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

fmt.Fprint(conn, "ping")
buf := make([]byte, 1024)
n, _ := conn.Read(buf)
fmt.Println(string(buf[:n]))   // ping

For more control, net.ResolveUDPAddr plus net.DialUDP or net.ListenUDP give you the typed *net.UDPConn with ReadFromUDP/WriteToUDP, which is handy when a server needs to send unsolicited datagrams to specific peers.

Datagram Size and Truncation

Size your read buffer for the largest datagram you expect, because UDP does not stream. If a datagram is bigger than your buffer, the extra bytes are discarded, not saved for the next read the way a TCP stream would carry them over:

buf := make([]byte, 512)          // too small for a 1400-byte datagram
n, _, _ := pc.ReadFrom(buf)       // n == 512, the rest is gone forever

To stay safe, keep payloads well under the network's MTU (maximum transmission unit), the largest chunk the network carries in one packet, roughly 1500 bytes on Ethernet, so a datagram fits in a single packet and avoids IP fragmentation. IP fragmentation is the network splitting an oversized packet into smaller pieces to get it through, which adds overhead and more chances to lose the whole thing. Many protocols cap UDP payloads around 512 bytes for exactly this reason.

No Reliability by Default

UDP makes no promises. Datagrams can be dropped, duplicated, or arrive out of order, and the protocol won't tell you. If your application needs any of those guarantees, you either build them yourself on top (sequence numbers, acknowledgments, retransmission) or you use TCP instead. Reaching for UDP means accepting that "mostly delivered, mostly in order" is good enough, or that you'll add exactly the reliability you need and nothing more.

When UDP Wins

UDP is the right call whenever low latency beats guaranteed delivery, or when the data is naturally message-shaped:

  • DNS: one small query, one small reply, no need for a connection
  • Metrics and logs (statsd-style): fire and forget, a lost sample doesn't matter
  • Real-time media and games: a dropped video frame is better than a stalled stream waiting for a retransmit
  • Service discovery and broadcast: one datagram reaching many listeners

If you find yourself disabling TCP features to make them cheaper, UDP is probably what you actually wanted.

Key Takeaways

  • UDP is connectionless: no handshake, no ordering, no delivery guarantee. It preserves message boundaries (one WriteTo = one datagram = one ReadFrom), unlike TCP's boundary-free stream.
  • A server uses net.ListenPacket("udp", addr) returning a net.PacketConn, then loops on ReadFrom(buf) (returns bytes + sender addr) and replies with WriteTo(data, addr).
  • A client can net.Dial("udp", ...) for a connected net.Conn, or use net.ResolveUDPAddr + net.DialUDP/ListenUDP for the typed *net.UDPConn with ReadFromUDP/WriteToUDP.
  • Size the read buffer for the largest expected datagram; anything bigger is truncated and lost. Keep payloads under the MTU (~1500 bytes) to avoid fragmentation.
  • UDP gives no reliability; build sequencing/acks yourself or use TCP if you need them.
  • Choose UDP for DNS, metrics, real-time media/games, and discovery, where low latency beats guaranteed delivery.

🎁 You've now written servers that spin up a goroutine per connection and handle thousands of clients without breaking a sweat. But a blocking Read should tie up an entire OS thread, so how does Go run ten thousand of them on a handful of threads?

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy