Chapter 10: Network Programming
Rust is a systems language, which means talking to the network is a first-class concern. The standard library gives you synchronous TCP and UDP; the ecosystem (Tokio, Hyper) gives you high-performance async networking. This chapter moves from raw sockets up to an HTTP server, so you understand each layer rather than just calling a framework.
Learning Objectives
- Open TCP and UDP connections with
std::net. - Build a simple synchronous TCP echo server and client.
- Use Tokio for async, concurrent network handling.
- Serve a minimal HTTP request with
hyperor a tiny hand-rolled parser. - Serialize and deserialize structured data with
serde.
10.1 TCP with the standard library
std::net::TcpStream is a bidirectional byte stream. The simplest client connects, writes, and reads back:
use std::io::{prelude::*, BufReader}; use std::net::TcpStream; fn main() -> std::io::Result<()> { let mut stream = TcpStream::connect("example.com:80")?; // Send an HTTP/1.0 request by hand. write!(stream, "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")?; // Read the first line of the response. let mut reader = BufReader::new(stream); let mut status = String::new(); reader.read_line(&mut status)?; println!("{status}"); Ok(()) }
A TCP server accepts connections in a loop:
use std::io::{prelude::*, BufReader}; use std::net::{TcpListener, TcpStream}; fn handle(mut stream: TcpStream) -> std::io::Result<()> { let mut reader = BufReader::new(&stream); let mut line = String::new(); reader.read_line(&mut line)?; println!("received: {line}"); stream.write_all(b"ack\n")?; Ok(()) } fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:7878")?; for stream in listener.incoming() { let stream = stream?; handle(stream)?; } Ok(()) }
TcpListener::bind returns io::Result because binding can fail (port in use). incoming() is an iterator yielding one io::Result<TcpStream> per connection.
Handling one client per thread
The synchronous server above processes clients serially. To serve them concurrently, move each connection onto its own thread:
use std::net::TcpListener; use std::thread; fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:7878")?; for stream in listener.incoming() { let stream = stream?; thread::spawn(move || { // handle(stream) — see above let _ = std::io::copy(&mut &stream[..], &mut &stream[..]); }); } Ok(()) }
This scales to thousands of idle connections but spends an OS thread per client — fine for many workloads, wasteful for very high concurrency, which is where async shines.
10.2 UDP
UDP is connectionless: you send datagrams without establishing a stream.
use std::net::UdpSocket; fn main() -> std::io::Result<()> { let socket = UdpSocket::bind("127.0.0.1:34254")?; let mut buf = [0; 1024]; // Echo received datagrams back to their sender. loop { let (amt, src) = socket.recv_from(&mut buf)?; socket.send_to(&buf[..amt], src)?; } }
Use UDP when a lost packet is acceptable (telemetry, games, DNS) or when you implement a reliability layer yourself.
10.3 Async networking with Tokio
Tokio provides non-blocking TCP/UDP with the same API shape, prefixed with Async. The advantage: a single thread can wait on tens of thousands of sockets at once via the OS's I/O multiplexer (epoll/kqueue/IOCP).
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:7878").await?; loop { let (mut socket, _) = listener.accept().await?; // Spawn a task per connection — cheap, not an OS thread. tokio::spawn(async move { let mut buf = [0; 1024]; loop { let n = match socket.read(&mut buf).await { Ok(0) => return, // peer closed Ok(n) => n, Err(_) => return, }; if socket.write_all(&buf[..n]).await.is_err() { return; } } }); } }
This is an echo server that handles many clients concurrently on a small pool of threads. Each tokio::spawn creates a lightweight task, not an OS thread.
10.4 A minimal HTTP server
HTTP/1.1 is text on top of TCP. A tiny server can parse just the request line and respond:
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; async fn handle(mut stream: TcpStream) -> std::io::Result<()> { let mut reader = BufReader::new(&mut stream); let mut request_line = String::new(); reader.read_line(&mut request_line).await?; let (method, path) = parse_request_line(&request_line); let body = match (method.as_str(), path.as_str()) { ("GET", "/") => "hello, world".to_string(), ("GET", "/time") => format!("{}", chrono::Utc::now()), _ => "not found".to_string(), }; let status = if path == "/" || path == "/time" { "200 OK" } else { "404 Not Found" }; let response = format!( "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len() ); stream.write_all(response.as_bytes()).await?; Ok(()) } fn parse_request_line(line: &str) -> (String, String) { let mut parts = line.split_whitespace(); let method = parts.next().unwrap_or("").to_string(); let path = parts.next().unwrap_or("").to_string(); (method, path) } #[tokio::main] async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:8080").await?; loop { let (stream, _) = listener.accept().await?; tokio::spawn(async move { if let Err(e) = handle(stream).await { eprintln!("error: {e}"); } }); } }
For anything beyond a toy, reach for a framework — axum, actix-web, or hyper directly — which handles chunked encoding, keep-alive, routing, and TLS correctly.
10.5 Serialization with serde
Network data is bytes; your program wants structs. serde is the standard serialization framework, and serde_json is its JSON frontend.
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug)] struct User { name: String, age: u8, } fn main() { let user = User { name: "alice".into(), age: 30 }; // Serialize to JSON. let json = serde_json::to_string(&user).unwrap(); println!("{json}"); // {"name":"alice","age":30} // Deserialize back. let parsed: User = serde_json::from_str(&json).unwrap(); println!("{parsed:?}"); }
serde works with many formats — bincode (compact binary), toml, yaml, protobuf via prost — all behind the same Serialize/Deserialize derive.
10.6 Best Practices
- Use
BufReader/BufWriter. Reading byte-by-byte off a socket is catastrophically slow; buffering is almost always right. - Bound your reads. Never allocate a buffer based on an untrusted length field without a cap — a classic denial-of-service vector.
- Set timeouts. A socket that never receives data can hang forever. Use
stream.set_read_timeout(Some(...))or, in async,tokio::time::timeout. - Prefer async for high fan-out. If you expect thousands of concurrent connections, a thread-per-connection model wastes memory.
- TLS in production. Plaintext TCP is fine for learning; for anything exposed to the internet, terminate TLS (e.g.
rustls).
10.7 Summary
std::net gives you synchronous TCP and UDP; Tokio gives you the same primitives non-blocking, so one thread can manage thousands of sockets. HTTP is text on TCP, and a small server is within reach, though production code should lean on axum or hyper. Everywhere, serde moves between bytes and typed structs. Network programming in Rust is low-level when you need it to be and ergonomic when you want it to be.
Exercises
- Extend the sync TCP server so it echoes each line back to the client until the client disconnects.
- Convert it to async with Tokio, and add a 5-second read timeout per connection.
- Build a JSON-over-TCP server that receives a
serderequest struct and replies with a response struct.