How to Create a UDP Server and Client in Go

Web
Create a UDP server and client in Go using the net package to listen for and send datagrams without a persistent connection.

When TCP feels like overkill

You are building a real-time game. A player moves their character. You need to send the new coordinates to the server. You do not care if the packet gets lost. The next frame will contain the updated position, and the old one becomes irrelevant. TCP adds latency with handshakes, acknowledgments, and retransmissions. It guarantees delivery, but that guarantee costs time. UDP is the alternative. It is a datagram protocol. You write a message, the network does its best to deliver it, and you get zero guarantees. It is fast, lightweight, and connectionless.

What is a datagram

A datagram is a self-contained unit of data. It has a source address, a destination address, and a payload. The network delivers it best-effort. There is no connection state between the sender and receiver. Each packet stands alone. If a packet is lost, the protocol does not retry. If packets arrive out of order, the protocol does not reorder them. You handle reliability at the application layer if you need it.

Think of UDP like sending a postcard. You write the message, address it, and drop it in the mailbox. It might arrive. It might get lost. It might arrive after the next postcard. You do not wait for a confirmation. TCP is like a phone call. You wait for the other person to pick up. You talk back and forth. If the line drops, you try again. UDP is fire-and-forget. If you need reliability, build it yourself or pick TCP.

Minimal server

The server binds to a port and waits for datagrams. It uses net.ListenUDP to create a connection. It uses net.ReadFromUDP to read packets. It uses net.WriteToUDP to send responses. The server must know the sender's address for every write because UDP has no persistent connection.

Here is the simplest UDP server: resolve the address, listen, loop reads, and echo back.

package main

import (
	"log"
	"net"
)

func main() {
	// Resolve address. "udp" sets the network type.
	addr, err := net.ResolveUDPAddr("udp", ":8080")
	if err != nil {
		log.Fatal(err)
	}

	// ListenUDP binds to the port. No handshake occurs.
	conn, err := net.ListenUDP("udp", addr)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	// Buffer to hold incoming datagrams.
	buf := make([]byte, 1024)

	for {
		// ReadFromUDP blocks until a packet arrives.
		// Returns byte count and sender's address.
		n, remoteAddr, err := conn.ReadFromUDP(buf)
		if err != nil {
			log.Fatal(err)
		}

		// WriteToUDP sends response back to the specific sender.
		// Every write needs a destination address in UDP.
		_, _ = conn.WriteToUDP(buf[:n], remoteAddr)
	}
}

The code starts by resolving the address. net.ResolveUDPAddr parses the string :8080 and returns a *net.UDPAddr. The udp argument specifies the network type. The server then calls net.ListenUDP. This binds the connection to the port. It does not establish a connection to a remote peer. The defer conn.Close() ensures the port is released when the program exits.

The loop uses ReadFromUDP. This method blocks until a datagram arrives. It returns three values: the number of bytes read, the sender's address, and an error. The byte count is crucial. The buffer might be larger than the packet. You must slice the buffer with buf[:n] to avoid sending garbage data. The server then calls WriteToUDP. This sends the response back to the specific sender. Every write in UDP requires a destination address.

Minimal client

The client connects to the server and sends a datagram. It uses net.DialUDP to create a connection. It uses net.Write to send data. It uses net.Read to receive responses. The client knows the server's address from the dial, so it does not need to specify it for every write.

Here is the simplest UDP client: resolve the server address, dial, write, and read.

package main

import (
	"log"
	"net"
)

func main() {
	// Resolve address.
	addr, err := net.ResolveUDPAddr("udp", "localhost:8080")
	if err != nil {
		log.Fatal(err)
	}

	// DialUDP creates a connection.
	// nil local address lets the OS pick a port.
	conn, err := net.DialUDP("udp", nil, addr)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	// Write sends the datagram to the address set by DialUDP.
	_, err = conn.Write([]byte("Hello Server"))
	if err != nil {
		log.Fatal(err)
	}

	// Buffer for the response.
	buf := make([]byte, 1024)

	// Read blocks until a response arrives from the server.
	n, err := conn.Read(buf)
	if err != nil {
		log.Fatal(err)
	}

	log.Printf("Received: %s", buf[:n])
}

The client resolves the server address. net.ResolveUDPAddr parses localhost:8080. The client then calls net.DialUDP. The second argument is the local address. Passing nil lets the operating system pick an available port. The DialUDP call sets the default remote address for the connection.

The client calls Write. This sends the datagram to the address set by DialUDP. It does not require a destination argument. The client then creates a buffer and calls Read. This blocks until a response arrives. The Read method returns the byte count and an error. The client slices the buffer and prints the response.

How the pieces fit together

The net package provides net.UDPAddr and net.UDPConn. net.UDPAddr holds the IP address, port, and zone. net.UDPConn represents the connection. The server uses ListenUDP to bind. The client uses DialUDP to set the remote address.

The server uses ReadFromUDP and WriteToUDP. These methods require addresses because the server handles multiple clients. The client uses Read and Write. These methods use the default address from DialUDP. This distinction matters. If the client calls WriteToUDP, it can send to a different address. If the server calls Write, it fails because there is no default remote address.

The if err != nil pattern is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. Network operations fail. Errors are values. Handle them or crash. The _ operator discards values intentionally. _, _ = conn.WriteToUDP(...) says "I considered the return values and chose to drop them". Use it sparingly with errors. In the minimal server, ignoring the write error is acceptable for a demo. In production, log it.

Realistic server with error handling

A realistic server handles errors gracefully. It logs read errors and continues. It logs write errors and keeps running. It processes the message and sends a response. It uses a buffer and slices it correctly.

Here is a server that echoes messages with a timestamp: resolve, listen, loop, process, and respond.

package main

import (
	"fmt"
	"log"
	"net"
	"time"
)

func main() {
	// Resolve address.
	addr, err := net.ResolveUDPAddr("udp", ":9090")
	if err != nil {
		log.Fatal(err)
	}

	// ListenUDP binds to the port.
	conn, err := net.ListenUDP("udp", addr)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	// Buffer for incoming datagrams.
	buf := make([]byte, 1024)

	for {
		// ReadFromUDP blocks until a packet arrives.
		n, remoteAddr, err := conn.ReadFromUDP(buf)
		if err != nil {
			log.Println("read error:", err)
			continue
		}

		// Process the message.
		msg := string(buf[:n])
		response := fmt.Sprintf("Echo: %s at %s", msg, time.Now().Format(time.Kitchen))

		// WriteToUDP sends the response back.
		_, err = conn.WriteToUDP([]byte(response), remoteAddr)
		if err != nil {
			log.Printf("send failed to %s: %v", remoteAddr, err)
		}
	}
}

The server resolves the address and listens. The loop reads packets. If ReadFromUDP returns an error, the server logs it and continues. This prevents a single bad packet from crashing the server. The server processes the message. It converts the buffer slice to a string. It formats a response with a timestamp. The server then calls WriteToUDP. If the write fails, the server logs the error. It does not crash. This is critical for long-running services.

The buf[:n] slice is essential. The buffer is 1024 bytes. The packet might be smaller. If you pass the whole buffer to WriteToUDP, you send garbage data. The byte count n tells you how much data is valid. Always slice the buffer.

Pitfalls and compiler errors

UDP has pitfalls. Buffers are finite. If a packet exceeds the buffer size, the data is truncated. The ReadFromUDP method returns the number of bytes read. If n equals the buffer length, the packet might be larger. You lose data. Increase the buffer size or handle fragmentation.

ReadFromUDP blocks. If no packets arrive, the goroutine waits. Use SetReadDeadline to add a timeout. This prevents the server from hanging forever. The deadline resets for each read. You must call it before every ReadFromUDP.

DialUDP takes a local address. Passing nil is standard. It lets the OS pick a port. If you pass a specific address, ensure it is valid. The compiler rejects invalid types. If you pass a string to Write instead of []byte, the compiler rejects it with cannot use "string" (untyped string constant) as []byte value in argument. Go is strict about types. Convert strings to bytes with []byte("text").

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. UDP does not use channels directly, but if you wrap the connection in a channel, ensure you close it. Always have a cancellation path. Context is plumbing. Run it through every long-lived call site. If you use context.Context, pass it as the first parameter. Name it ctx. Respect cancellation and deadlines.

When to use UDP

Use UDP when you need low latency and can tolerate lost packets. Use UDP when you are broadcasting to multiple clients and do not need individual acknowledgments. Use UDP when you are building a real-time game or a voice chat application. Use TCP when you need reliable delivery and ordered data. Use TCP when you are transferring files or downloading web pages. Use HTTP when you need a structured request-response model with headers and status codes.

UDP is fast. TCP is safe. Pick the right tool.

Where to go next