The fire-and-forget socket
You are building a multiplayer game where player positions update 60 times a second. A lost packet means a player teleports slightly, which the interpolation logic smooths out. You do not want the network stack to pause the game trying to retransmit a stale position. Or you are writing a DNS resolver that needs to blast out queries and grab the first answer that arrives. TCP's handshake and retransmission logic adds latency you cannot afford. You need UDP.
Rust gives you std::net::UdpSocket. It behaves exactly how the OS expects UDP to behave: fast, unreliable, and stateless. There are no connections to manage. There is no backlog queue. You bind to a port, you receive datagrams, you send datagrams. If the other side is down, the packet vanishes. Rust does not hide this reality. It forces you to handle the buffer and the addresses explicitly.
How datagrams move
Think of TCP as a registered letter with a return receipt. The post office tracks every step. If it gets lost, they resend it. UDP is a postcard. You write it, drop it in the box, and hope it arrives. It might get lost. It might arrive after the next postcard. It might arrive with the corner torn off. But it gets there fast, and you do not pay for the tracking.
In Rust, UdpSocket is your mailbox. You call bind to tell the OS which port you want to listen on. Unlike TCP, there is no listen or accept. You are ready immediately. When data arrives, recv_from fills a buffer you provide and tells you how many bytes it wrote and where they came from. When you want to send, send_to takes your data and a destination address. The OS routes it. You do not know if it arrives.
UdpSocket implements Send and Sync. This means you can move the socket to another thread or share it across threads safely. The OS handles the underlying file descriptor concurrency. You do not need a mutex to share a UdpSocket between threads.
Minimal example
Here is a server that binds to a port, waits for one packet, and sends a reply. The client creates a socket, sends a message, and waits for the reply.
// server.rs
use std::net::UdpSocket;
/// Binds to a port, receives one datagram, and echoes a response.
fn main() {
// Bind to localhost on port 8080.
// unwrap() panics on error; production code should handle this.
let socket = UdpSocket::bind("127.0.0.1:8080").unwrap();
// Allocate a buffer on the stack.
// 1024 bytes is sufficient for most UDP payloads.
let mut buf = [0; 1024];
// Block until a packet arrives.
// recv_from returns the byte count and the sender's address.
let (len, addr) = socket.recv_from(&mut buf).unwrap();
// Slice the buffer by length to avoid reading uninitialized bytes.
let message = String::from_utf8_lossy(&buf[..len]);
println!("Received {} bytes from {}: {}", len, addr, message);
// Send a response back to the sender.
socket.send_to(b"Hello from server!", addr).unwrap();
}
// client.rs
use std::net::UdpSocket;
/// Sends a datagram to the server and prints the reply.
fn main() {
// Bind to port 0 to let the OS pick an ephemeral port.
// This is required if you want to receive a reply on the same socket.
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
// Send data to the server.
socket.send_to(b"Hello from client!", "127.0.0.1:8080").unwrap();
let mut buf = [0; 1024];
let (len, _) = socket.recv_from(&mut buf).unwrap();
// Slice by length and convert to string.
let reply = String::from_utf8_lossy(&buf[..len]);
println!("Received: {}", reply);
}
Run the server first. Then run the client. The server prints the message and exits. The client prints the reply and exits.
The buffer discipline
UDP requires you to manage buffers manually. recv_from does not allocate memory for you. It writes into the slice you pass. It returns the number of bytes written. The buffer retains its previous contents beyond that length. If you print the whole buffer, you see garbage.
Always slice by the returned length. &buf[..len] is the correct pattern. String::from_utf8_lossy is the safe way to convert bytes to a string. It replaces invalid UTF-8 sequences with the replacement character instead of panicking. UDP payloads might contain binary data. Assuming UTF-8 without checking is a bug.
Convention aside: The community expects you to slice buffers by length. Writing &buf instead of &buf[..len] is a signal that you do not understand how recv_from works. Reviewers will flag it immediately.
Realistic server
A real server runs in a loop. It handles errors. It might need to send to multiple clients. Here is a structure that handles the basics.
// server.rs
use std::io;
use std::net::UdpSocket;
/// A UDP echo server that runs indefinitely and handles errors.
fn main() {
let socket = match UdpSocket::bind("127.0.0.1:8080") {
Ok(sock) => sock,
Err(e) => panic!("Failed to bind: {}", e),
};
let mut buf = [0; 1024];
loop {
// recv_from blocks until data arrives.
// In a production app, consider set_nonblocking or an async runtime.
let (len, addr) = match socket.recv_from(&mut buf) {
Ok(result) => result,
Err(e) => {
eprintln!("Receive error: {}", e);
continue;
}
};
let message = String::from_utf8_lossy(&buf[..len]);
println!("Got: {} from {}", message, addr);
// Echo the message back.
if let Err(e) = socket.send_to(message.as_bytes(), addr) {
eprintln!("Send error: {}", e);
}
}
}
This loop blocks on recv_from. If you need to do other work while waiting, you must use set_nonblocking or move the socket to a thread. UdpSocket is Send, so you can move it into a thread easily.
The connect shortcut
UdpSocket has a method called connect. This does not establish a connection. UDP is connectionless. connect sets a default destination address. It also filters incoming packets. After calling connect, you can use send and recv instead of send_to and recv_from.
use std::net::UdpSocket;
fn main() {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
// Set the default peer.
// This does not send a SYN or anything similar.
// It just configures the socket.
socket.connect("127.0.0.1:8080").unwrap();
// Now you can use send without an address.
socket.send(b"Hello!").unwrap();
// recv only returns packets from the connected address.
let mut buf = [0; 1024];
let len = socket.recv(&mut buf).unwrap();
println!("Got: {}", String::from_utf8_lossy(&buf[..len]));
}
Use connect when you are talking to a single peer and want to reduce boilerplate. It simplifies the code by removing the address argument from every call. It also drops packets from other sources, which can be a security benefit. The method name is historical and confusing. It does not create a connection. It sets a filter.
Pitfalls and compiler errors
UDP code has specific traps. The compiler catches some. The runtime hides others.
If you forget mut on the buffer, the compiler rejects you with E0596 (cannot borrow as mutable). recv_from needs to write into the buffer. You must pass &mut buf.
If you pass a String directly to send_to, you get E0277 (trait bound not satisfied). send_to expects &[u8] or AsRef<[u8]>. A String does not implement this directly. Use message.as_bytes() or &message[..].
If you pass an integer where an address is expected, you get E0308 (mismatched types). send_to takes impl ToSocketAddrs. This trait accepts strings, tuples like ("127.0.0.1", 8080), and SocketAddr structs. It does not accept raw integers.
send_to returning Ok does not mean the packet arrived. It means the OS accepted the packet for delivery. The packet might be dropped by the network. The server might be down. UDP does not report delivery failures. If you need reliability, you must implement it in your application layer.
recv_from blocks forever if no packet arrives. If you need a timeout, you must use set_nonblocking and handle io::ErrorKind::WouldBlock. Or use set_read_timeout. Blocking on UDP in a single-threaded app freezes everything.
Decision matrix
Use UdpSocket when you need low latency and can tolerate packet loss, like in real-time games, voice chat, or sensor streaming.
Use TcpStream when data integrity matters more than speed, like file transfers, HTTP, or database connections.
Use socket.connect on a UDP socket when you are talking to a single peer and want to simplify your code by using send and recv instead of passing addresses every time.
Use send_to when you are broadcasting or talking to multiple different peers on the same socket.
Use set_nonblocking when you are building a custom event loop and cannot afford to block the thread on I/O.
Use Rc or Arc to share a UdpSocket across threads if you need multiple owners, though UdpSocket is Send and Sync so you can also move it or clone the inner state via try_clone.