When reliability gets in the way
You are building a multiplayer game where a player's position updates arrive at 60 frames per second. A network hiccup delays one packet by 200 milliseconds. By the time it arrives, the player has moved three meters. Delivering that old update would cause the character to teleport backward, breaking immersion and ruining gameplay. You do not want the network to guarantee delivery. You want the data to arrive fast, and if it drops, that is acceptable. The next packet contains the current position anyway.
This is UDP. It is the protocol for when speed, low overhead, and simplicity matter more than guaranteed delivery. UDP does not establish a connection. It does not reorder packets. It does not retry failed sends. It throws data at the network and hopes for the best. In Rust, std::net::UdpSocket gives you direct access to this behavior with zero allocation overhead and full control over the datagram lifecycle.
The megaphone protocol
TCP is like registered mail. You get a receipt, the carrier calls you if the address is wrong, and packages arrive in the exact order you sent them. UDP is like shouting a message across a busy canyon. You yell it out. Maybe the other person hears it. Maybe a gust of wind carries it away. Maybe they hear a fragment. You do not get a receipt. You just keep yelling. The receiver has to deal with the noise, the gaps, and the order.
Rust's UdpSocket models this faithfully. There is no connect handshake. There is no stream of bytes. You send discrete chunks of data called datagrams. Each datagram carries its own destination address. The operating system delivers them independently. Your code must handle the fact that datagrams can arrive out of order, be duplicated, or vanish entirely.
The minimal server
A UDP server binds to a port and loops, waiting for datagrams. The core method is recv_from, which blocks the current thread until data arrives. It returns a tuple containing the number of bytes read and the sender's address.
use std::net::UdpSocket;
/// A minimal UDP server that prints received messages to stdout.
fn main() -> std::io::Result<()> {
// Bind to localhost on port 8080.
// This reserves the port and creates the socket.
// Binding to 127.0.0.1 restricts access to the local machine.
let socket = UdpSocket::bind("127.0.0.1:8080")?;
// Allocate a buffer on the stack to hold incoming data.
// 1024 bytes handles most small messages without heap allocation.
// The buffer is reused across iterations to avoid repeated allocations.
let mut buf = [0; 1024];
loop {
// Block until a datagram arrives.
// Returns the number of bytes read and the sender's SocketAddr.
// If the socket is non-blocking, this may return WouldBlock.
let (len, addr) = socket.recv_from(&mut buf)?;
// Convert the raw bytes to a string for display.
// from_utf8_lossy replaces invalid UTF-8 sequences with the replacement character.
// This prevents panics if the sender transmits binary data.
let message = String::from_utf8_lossy(&buf[..len]);
println!("Received {} bytes from {}: {}", len, addr, message);
}
}
Slice the buffer. The compiler will not stop you from printing garbage, but your users will notice.
Walkthrough
bind attaches the socket to an address. If another process already owns that port, the call fails with an io::Error. You can bind to 0.0.0.0 to listen on all network interfaces, or 127.0.0.1 to restrict access to the local machine. Choose based on your security requirements.
recv_from fills the buffer with the incoming datagram. It returns (usize, SocketAddr). The usize is the length of the data written to the buffer. The buffer retains its previous contents beyond that length. You must slice &buf[..len] to access only the new data. Passing the entire buffer to a string converter or a network send will include stale bytes from previous reads.
String::from_utf8_lossy is the safe way to interpret bytes as text. UDP is often used for binary protocols. If the sender transmits raw integers or compressed data, from_utf8 would panic on invalid sequences. from_utf8_lossy substitutes invalid bytes with the Unicode replacement character, allowing the program to continue.
Handling the reply
UDP is stateless. The server does not maintain a connection to the client. To reply, you must use the address returned by recv_from. The send_to method takes a slice of data and a destination address.
use std::net::UdpSocket;
/// Echo server: receives a datagram and sends it back to the sender.
fn main() -> std::io::Result<()> {
let socket = UdpSocket::bind("127.0.0.1:9999")?;
let mut buf = [0; 1024];
loop {
// Receive data and capture the sender's address.
let (len, addr) = socket.recv_from(&mut buf)?;
// Echo the data back to the source.
// send_to takes a slice and a SocketAddr.
// The slice must match the received length to avoid sending stale data.
socket.send_to(&buf[..len], addr)?;
}
}
UDP is stateless. Every packet must carry the context it needs.
Convention: Buffer sizing
Community convention is to size UDP buffers to the maximum packet size your protocol defines. If you are building a simple chat server, 1024 bytes is sufficient. If you are streaming audio samples, you might increase the buffer to 4096 bytes. Do not allocate a megabyte buffer for a heartbeat protocol. Large buffers waste stack space and increase cache pressure. If a packet exceeds the buffer size, recv_from truncates it and returns the bytes that fit. The rest of the packet is discarded silently. There is no error. You lose data. Size the buffer for your protocol, or accept that large packets will be dropped.
Pitfalls and compiler traps
UDP servers introduce specific failure modes. The compiler catches some, but others require runtime checks.
Buffer type errors
If you pass the array directly to String::from_utf8, the compiler rejects you with E0308 (mismatched types). The function expects a Vec<u8> or a slice, not a fixed-size array. Slice the buffer first.
// This fails with E0308: mismatched types.
// String::from_utf8 expects Vec<u8>, not [u8; 1024].
let bad = String::from_utf8(buf)?;
// This works: slice the buffer to get &[u8].
let good = String::from_utf8_lossy(&buf[..len]);
Sharing sockets across threads
UdpSocket does not implement Clone. If you try to clone the socket to share it with a worker thread, the compiler rejects you with E0277 (trait bound not satisfied). Wrap the socket in Arc to share ownership.
use std::net::UdpSocket;
use std::sync::Arc;
// This fails with E0277: UdpSocket is not Clone.
// let socket_clone = socket.clone();
// Wrap in Arc to share across threads.
let socket = Arc::new(UdpSocket::bind("127.0.0.1:8080")?);
let sender_socket = Arc::clone(&socket);
Wrap sockets in Arc before you spawn threads. It saves the refactor later.
Non-blocking mode and WouldBlock
By default, recv_from blocks the thread. If you need to handle multiple sockets or perform other work while waiting, set the socket to non-blocking mode. In this mode, recv_from returns immediately with WouldBlock if no data is available.
use std::io::{ErrorKind, Read};
socket.set_nonblocking(true)?;
match socket.recv_from(&mut buf) {
Ok((len, addr)) => {
// Process data.
}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
// No data available. Do other work and retry later.
}
Err(e) => return Err(e),
}
Check the error kind. WouldBlock is the heartbeat of a non-blocking loop.
Port reuse
If you stop and restart the server quickly, the operating system may keep the port in a TIME_WAIT state. The next bind call fails. Call set_reuseaddr(true) to allow binding to a port that is in this state. This is standard practice for development servers and daemons.
socket.set_reuseaddr(true)?;
Set reuseaddr before you bind. It prevents the "address already in use" error during rapid restarts.
Decision matrix
Use UdpSocket when you need low latency and can tolerate packet loss. Use TcpStream when you need reliable, ordered delivery and can handle the overhead of connection management.
Use recv_from when you need to know the sender's address to reply or filter traffic. Use recv when you have called connect to set a default peer and only care about the data.
Use connect on a UDP socket when you want to restrict the socket to a single peer and use send/recv for slightly cleaner code. Use bind when you are building a server that accepts datagrams from multiple sources.
Use Arc<UdpSocket> when you need to share the socket across multiple threads. Use a single thread with recv_from when the workload fits in one core and you want to avoid synchronization overhead.
Use set_nonblocking(true) when you are polling multiple sockets or integrating with an event loop. Use blocking mode when you have a dedicated thread per socket and want the simplest code.
Use String::from_utf8_lossy when you expect mixed text and binary data and want to avoid panics. Use String::from_utf8 when you control the protocol and can guarantee valid UTF-8.