UDP in Depth: Protocol Theory and Go Implementation
"Imagine sending postcards—fast, simple, and direct. Sometimes they get lost, sometimes they arrive out of order, but they're perfect for quick messages! UDP is the postcard protocol, and Go lets you send and receive them with ease."
What is UDP?
UDP (User Datagram Protocol) is the speedster of the networking world. It's like tossing paper airplanes—no guarantee they'll arrive, but they're fast and don't wait for a reply.
- Connectionless: No handshake, just send and hope for the best.
- Unreliable: No delivery confirmation—packets may be lost or duplicated.
- No ordering: Packets can arrive in any order.
- Lightweight: Minimal overhead, perfect for real-time apps.
Analogy:
- UDP is like shouting across a crowded room—some people hear you, some don't, but it's quick!
UDP delivers datagrams, never a stream — each successful read returns exactly one packet, or none.
How UDP Works (Theory)
- No Handshake:
- Just send data—no setup required.
- Datagrams:
- Each message is a self-contained packet (datagram).
- No Guarantees:
- Delivery, order, and duplication are not managed by UDP.
Diagram:
[Sender] --UDP Packet--> [Receiver]
[Sender] --UDP Packet--> [Receiver]
(No ACKs, no order, just speed)
ReadFrom, the extra bytes are dropped — there is no "read the rest next time" like there is with a TCP stream. Always size your receive buffer for the largest datagram your protocol can produce, and treat n == len(buf) as a signal that truncation may have happened.MTU, Fragmentation, and Packet Loss
Ethernet's typical MTU (maximum transmission unit) is 1500 bytes, which after IP and UDP headers leaves roughly 1472 bytes of usable payload before the network layer has to step in. A UDP datagram can legally be larger than that — up to about 65507 bytes of payload over IPv4 — but anything above the path's MTU forces IP to fragment it into several IP packets that get reassembled at the destination.
Go in Action: Simple UDP Client (Step by Step)
This example shows how to send a UDP message to a server and read the response, without blocking forever if nothing comes back.
package main
import (
"fmt" // Print to the console
"net" // Networking primitives
"os"
"time"
)
func main() {
// 1. net.Dial creates a UDP "connection": no handshake happens,
// it just remembers the remote address for Read/Write.
conn, err := net.Dial("udp", "localhost:9001")
if err != nil {
fmt.Println("Error connecting:", err)
os.Exit(1)
}
defer conn.Close()
// 2. Send a message
if _, err := fmt.Fprintf(conn, "Hello UDP server!\n"); err != nil {
fmt.Println("Error sending:", err)
os.Exit(1)
}
// 3. UDP gives no delivery guarantee: a lost request, or a lost
// reply, would block Read forever without a deadline.
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
fmt.Println("No reply within 3s: request or reply was lost")
return
}
fmt.Println("Error reading:", err)
return
}
fmt.Println("Response:", string(buf[:n]))
}
- What does each part do?
net.Dial: prepares the UDP channel (no handshake).fmt.Fprintf: sends a message to the server.SetReadDeadline: bounds how long we wait for a reply that may never come.conn.Read: waits for the response (if it arrives).conn.Close(deferred): closes the channel.
net.Dial("udp", ...) almost never fails, even if nothing is listening on the other end — UDP has no handshake to fail. All Dial does here is record the local route and remote address for a connectionless socket. The first real signal that something is wrong is either a timeout waiting for a reply (as handled above) or, on the same host, an ICMP "port unreachable" message that can surface as an error on a later Write or Read call, not on Dial itself.Go in Action: Simple UDP Server (Explained)
This UDP server listens on port 9001 and replies to every message it receives.
package main
import (
"fmt"
"net"
)
func main() {
// 1. net.ListenPacket creates a UDP listener
conn, err := net.ListenPacket("udp", ":9001")
if err != nil {
fmt.Println("Error:", err)
return
}
defer conn.Close() // Release the socket when main returns
fmt.Println("UDP server listening on :9001")
buf := make([]byte, 1024)
for {
// 2. Read one datagram and the client's address
n, addr, err := conn.ReadFrom(buf)
if err != nil {
fmt.Println("Error reading:", err)
continue
}
// If n equals len(buf), the datagram may have been larger
// than our buffer and silently truncated.
if n == len(buf) {
fmt.Println("Warning: datagram may be truncated")
}
fmt.Printf("Received from %s: %s", addr, string(buf[:n]))
// 3. Reply to the client
conn.WriteTo([]byte("Hello from the Go UDP server!\n"), addr)
}
}
- What does each part do?
net.ListenPacket: opens the UDP port.conn.ReadFrom: reads data and the client's address.conn.WriteTo: sends the reply to the client.- The loop lets one goroutine serve every client, one datagram at a time.
Scaling Up: One Goroutine per Datagram
If handling a datagram involves real work — a database call, a
lookup, encryption — doing it inline blocks the read loop, and the
kernel's receive buffer fills up while you wait, causing new
datagrams to be dropped. Handing each datagram to its own goroutine
fixes that, but the shared buf slice makes this easy to get wrong:
buf := make([]byte, 1024)
for {
n, addr, err := conn.ReadFrom(buf)
if err != nil {
fmt.Println("Error reading:", err)
continue
}
// Copy before handing off: the next call to ReadFrom will
// overwrite buf before this goroutine is scheduled to run.
payload := make([]byte, n)
copy(payload, buf[:n])
go func(data []byte, from net.Addr) {
fmt.Printf("Received from %s: %s", from, string(data))
if _, err := conn.WriteTo(
[]byte("Hello from the Go UDP server!\n"), from,
); err != nil {
fmt.Println("Error replying:", err)
}
}(payload, addr)
}
buf[:n] directly into a new goroutine without copying it first is a classic bug: the main loop calls ReadFrom again almost immediately, overwriting the same backing array while the goroutine you just spawned might still be reading it. The result is not a crash you can reliably reproduce — it's corrupted or half-mixed data that only shows up under load. go run -race will catch this if you test it, but the fix is simpler: always copy the payload into its own slice before it crosses a goroutine boundary.Real-World Example: UDP Broadcast (Explained)
UDP lets you send a message to every device on a local network using a broadcast address.
package main
import (
"fmt"
"net"
)
func main() {
conn, _ := net.Dial("udp", "255.255.255.255:9002")
fmt.Fprintf(conn, "UDP broadcast message!\n")
conn.Close()
}
- What does each part do?
net.Dialwith a broadcast address targets every device on the local network.fmt.Fprintfsends the message.
sendto: permission denied. The kernel refuses broadcast traffic on a socket unless the SO_BROADCAST option has been explicitly enabled, and Go's plain net.Dial does not set it for you. To actually send broadcast packets you need to reach into the raw socket — for example with net.ListenConfig.Control or the golang.org/x/net/ipv4 helpers — and set SO_BROADCAST before the first write.What Go Does Behind the Scenes
- UDP requires no handshake and keeps no connection state: Go simply sends and receives datagrams.
net.ListenPacketandnet.Dialmake operating system calls to open UDP sockets, just like their TCP counterparts.- Go manages buffers and concurrency, but guarantees neither delivery nor ordering — that responsibility stays with your application.
- The
netpackage is cross-platform: your UDP code behaves the same on any operating system.
Visual Summary
[Client] --UDP--> [Server]
| |
[Send] [Receive]
| |
(No ACK, no order, just speed)
Try It Yourself: Build a Tiny Reliability Layer
UDP gives you speed but no guarantees. Build the smallest possible reliability layer on top of it, using the client and server above as your starting point:
- Add a 4-byte big-endian sequence number to the front of every
message the client sends (
encoding/binary.BigEndian.PutUint32). - On the server, echo the same sequence number back inside the reply so the client can match responses to requests.
- On the client, resend the same message (same sequence number) if no matching reply arrives within, say, 500ms, up to a small retry limit — this is the same idea behind TCP's retransmission timer, just implemented by hand in user space.
- To see it matter, add a line to the server that randomly drops
1-in-5 incoming datagrams before replying (
if rand.Intn(5) == 0 { continue }), and confirm your client's retry logic recovers.
This is a small-scale version of what QUIC, TFTP, and custom game networking stacks do for real: use UDP for its low overhead, then add back only the reliability guarantees the application actually needs.
What UDP gives up in reliability, it gives back in latency.
Frequently Asked Questions
If net.Dial("udp", ...) never really fails, how do I know my server is actually there?
You mostly don't, at least not right away — that's the whole point of a connectionless protocol. The honest signal is a timeout on the first Read, exactly like the client example above uses SetReadDeadline to avoid hanging forever. On the same host you might also get lucky and see an ICMP "port unreachable" surface as an error on a later Write or Read, but across the open internet, silence is often the only answer you'll ever get.
My UDP server is dropping packets even though the network looks perfectly healthy. What's going on?
Chances are your read loop is falling behind and the kernel's receive buffer for that socket is quietly overflowing — UDP has no flow-control signal like TCP's advertised window, so the kernel just discards new datagrams once the buffer is full. If handling each datagram is slow, move the work off the read loop with a worker pool (see the goroutine-per-datagram section) rather than doing it inline between calls to ReadFrom.
Why does Go make me copy buf[:n] before handing it to a goroutine instead of just passing the slice?
Because ReadFrom reuses the same backing array on every call, and nothing stops the loop from calling it again the instant a goroutine is spawned — the new goroutine and the next read would then be racing over the same bytes. Copying the payload into its own slice first, as shown in the scaling-up example, is the cheap fix; go run -race will catch you if you forget.
I tried the broadcast example and got a permission error — is the code wrong?
No, the code is fine, the socket just isn't configured for it: the kernel refuses broadcast traffic unless SO_BROADCAST is explicitly set, and plain net.Dial never sets it. You have to drop down to a raw socket option, via net.ListenConfig.Control or the golang.org/x/net/ipv4 helpers, before that first write will succeed.
If UDP is this unreliable, why does anything serious use it? Because for some workloads a late packet is worse than a lost one — a stale video frame or a delayed voice sample isn't worth waiting for. That tradeoff is exactly what the "Try It Yourself" reliability layer above is meant to teach: protocols like QUIC and TFTP start from UDP's speed and bolt on only the guarantees they actually need, rather than accepting TCP's all-or-nothing bundle.