UDP is the other transport protocol Go's net package speaks fluently. Unlike TCP, it's connectionless, message-oriented, and offers no delivery guarantees, which makes it a fit for telemetry, price ticks, and quick availability probes where speed beats reliability. This lesson covers how to build a UDP server in Go, how its single-socket model differs from TCP, and what trade-offs come with packet loss and size limits.
TCP is a connection-oriented stream protocol. It sets up a session, guarantees ordering, retries lost segments, and tears the connection down at the end. That bookkeeping costs round-trips and memory per connection.
UDP skips all of it. A UDP message, called a datagram, is sent as a single, self-contained packet. The sender writes a chunk of bytes, the receiver gets that exact chunk or nothing at all. There's no handshake, no acknowledgment, no retry. If a router drops the packet, it's gone.
This sounds bad until you look at what UDP is actually used for:
| Use Case | Why UDP Fits |
|---|---|
| DNS lookups | Single request, single response, retry handled at the app level |
| Price tick broadcasts | Latest value matters more than every value |
| Inventory availability pings | Cheap to ask, cheap to retry |
| Game state updates | Stale data is better than late data |
| Metrics and logs (StatsD) | Losing a metric sample is acceptable |
The two protocols compare as follows:
| Property | TCP | UDP |
|---|---|---|
| Connection | Required (handshake) | None |
| Delivery | Guaranteed | Best-effort |
| Ordering | Guaranteed | None |
| Boundaries | Stream, no message boundaries | Preserves message boundaries |
| Overhead per packet | ~20 bytes header | ~8 bytes header |
| Goroutine model in Go | One per connection | One per socket |
| Use when | You can't lose data | You can't afford the wait |
In Go, you open a UDP socket with net.ListenPacket. It returns a net.PacketConn, which is the connectionless cousin of net.Listener.
There's no Accept call, because there are no connections to accept. The socket is now ready to receive datagrams from any peer that sends one.
To read a datagram, call ReadFrom:
ReadFrom blocks until a datagram arrives. It returns three things:
n: how many bytes the datagram carried, written into buf.addr: the sender's address, which you'll need to reply.err: any error from the socket itself.The bytes you care about are buf[:n]. Anything past index n is leftover from a previous read or zero-initialized.
To reply, call WriteTo with the sender's address:
There is only one socket and one address per call. The server doesn't open a return channel to the client. It just sends a datagram back to wherever the request came from.
UDP has no flow control. If your ReadFrom loop falls behind, the kernel's receive buffer fills up and packets are silently dropped. There's no error, no retry, nothing. Keep the read loop tight, and offload work to other goroutines.
With TCP, the pattern was: Accept returns a new net.Conn per client, you launch a goroutine per connection, and each goroutine reads from its own conn until the client closes. That model works because TCP gives you one stream per peer.
UDP is different. There's no per-peer state. All datagrams arrive at the same socket regardless of who sent them. You can't spawn a goroutine per peer at the listening stage, because the listener never sees peers separately.
So the typical UDP server looks like this:
One goroutine reads from the socket. If individual requests take any real work, you hand the data off to another goroutine and go back to reading. The read loop's only job is to drain the socket fast enough that the kernel doesn't drop packets.
The diagram shows the shape clearly. Three clients fire datagrams at port 9000. All of them land in the same socket. A single goroutine pulls them out one at a time, then dispatches each one to a worker goroutine for processing.
Picking the read buffer size matters more in UDP than in TCP. With TCP, you can keep reading until you've got everything. With UDP, one ReadFrom call gets exactly one datagram. If the datagram is bigger than your buffer, the extra bytes are truncated and lost.
The practical upper bound on a single UDP payload is the path MTU minus the IP and UDP headers. On a typical Ethernet network:
Going larger forces IP-level fragmentation, where the OS splits the datagram into multiple IP packets. If even one fragment is lost, the whole datagram is dropped, since UDP doesn't retry. Most production UDP services keep payloads under 1472 bytes for this reason.
A 1500-byte buffer is a common default. It's the full MTU, so you won't truncate a maximum-sized payload, and the extra few bytes are negligible.
If your protocol uses smaller messages (a typical metrics line is well under 100 bytes), a smaller buffer is fine and saves allocations in a hot read loop.
Cost: Allocating a fresh 1500-byte slice on every ReadFrom is wasteful in a high-throughput server. Reuse a single buffer in the read loop and copy the relevant slice before handing it to a worker goroutine.
Time to put the pieces together. The server below is a price-tick broadcaster for a tiny inventory of products. A client sends PING <productID>, and the server replies with the current price as PRICE <productID> <cents>. The whole thing runs inside main(), with the server in a goroutine and a few client requests right after.
The server uses net.ListenPacket, which is unconnected: it can receive from any peer and reply to any peer. The client uses net.Dial("udp", ...), which returns a net.Conn bound to a single remote address. The bound socket is convenient for clients because Read and Write don't need to specify an address every call, and reads automatically filter out datagrams from anyone else.
The client also sets a read deadline. UDP has no built-in timeout, so without a deadline a lost reply would leave conn.Read blocked forever. Five hundred milliseconds is generous for localhost but realistic for handling packet loss on a real network.
The server's error replies (ERR bad request, ERR unknown kiwi) are an application-level convention, not a transport feature. UDP itself doesn't know anything about errors. If you want the client to learn that a product is unknown, you have to send a payload that says so.
Cost: net.Dial("udp", ...) opens a socket and registers a route, then closes it on Close. For one-off pings this is fine, but a client that pings many products in a tight loop should keep one connected socket and reuse it.
UDP delivers a datagram or it doesn't. There's no "delivered late" state. A well-behaved UDP service plans for loss instead of pretending it can't happen.
Three patterns are common:
apple twice produces the same answer.What you typically should not do is build a generic reliability layer on top of UDP. If you need ordered, guaranteed delivery, you're rebuilding TCP, and TCP exists.
For shutting the server down cleanly, set a read deadline before each loop iteration and check a stop signal:
The deadline causes ReadFrom to return with a timeout error every second, giving the loop a chance to notice the done channel was closed. There's no equivalent of http.Server.Shutdown for raw UDP. For UDP, plain pc.Close() is the shutdown mechanism, and deadlines are how you make the loop responsive in the meantime.
10 quizzes