Network Programming
This chapter covers network programming patterns—TCP/UDP for low-level protocols, HTTP client/server for web services, WebSocket for real-time bidirectional communication. Rust’s async ecosystem enables high-performance, concurrent network applications with safety guarantees.
Pattern 1: TCP Server/Client Patterns
Problem: Need reliable bidirectional communication between client and server. Simple echo server handles one client at time (blocks).
Solution: Use tokio’s async TcpListener and TcpStream. tokio::spawn() spawns task per connection.
Why It Matters: TCP foundation for HTTP, SSH, FTP, databases—essential protocol. Async I/O solves C10K problem (10K concurrent connections).
Use Cases: Chat servers (persistent connections per user), game servers (player connections), database protocols (Postgres, Redis), custom TCP protocols, proxy servers, load balancers, monitoring agents, message brokers, SSH servers.
Example: Async TCP Server Pattern
Synchronous TCP echo server using std::net::TcpListener that blocks on each client connection. The listener.incoming() iterator accepts connections sequentially, while handle_client uses blocking I/O. Only one client served at a time, demonstrating why async patterns are essential.
#![allow(unused)]
fn main() {
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write, BufReader, BufRead};
use std::thread;
fn simple_echo_server() -> std::io::Result<()> { // Sync, one client at a time
let listener = TcpListener::bind("127.0.0.1:8080")?;
println!("Server listening on port 8080");
// Accept connections in a loop
for stream in listener.incoming() {
match stream {
Ok(stream) => {
println!("New connection from: {}", stream.peer_addr()?);
handle_client(stream)?;
}
Err(e) => {
eprintln!("Connection failed: {}", e);
}
}
}
Ok(())
}
fn handle_client(mut stream: TcpStream) -> std::io::Result<()> {
let mut buffer = [0; 1024];
loop {
let bytes_read = stream.read(&mut buffer)?;
if bytes_read == 0 { println!("Client disconnected"); break; } // 0 = EOF
stream.write_all(&buffer[..bytes_read])?;
stream.flush()?;
}
Ok(())
}
simple_echo_server()?; // Blocks, handles one client at a time
}
This simple server has a critical limitation: it can only handle one client at a time. While one client is connected, other clients attempting to connect will have to wait. For production use, we need concurrent handling.
Example: Multi-threaded TCP Server
Spawns a dedicated OS thread per client using std::thread::spawn, consuming approximately 2MB stack space each. Handles concurrent connections but scales poorly—10K connections would require 20GB of memory. Thread context switching overhead motivates async approaches where lightweight tasks share fewer threads.
#![allow(unused)]
fn main() {
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
use std::thread;
fn multithreaded_server() -> std::io::Result<()> { // Thread per client (~2MB stack each)
let listener = TcpListener::bind("127.0.0.1:8080")?;
println!("Multi-threaded server listening on port 8080");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
thread::spawn(move || { // New thread per connection
if let Err(e) = handle_client_thread(stream) {
eprintln!("Error handling client: {}", e);
}
});
}
Err(e) => {
eprintln!("Connection failed: {}", e);
}
}
}
Ok(())
}
fn handle_client_thread(mut stream: TcpStream) -> std::io::Result<()> {
let addr = stream.peer_addr()?;
println!("Thread handling client: {}", addr);
let mut buffer = [0; 1024];
loop {
let bytes_read = stream.read(&mut buffer)?;
if bytes_read == 0 {
println!("Client {} disconnected", addr);
break;
}
// Echo back
stream.write_all(&buffer[..bytes_read])?;
}
Ok(())
}
multithreaded_server()?; // One thread per connection
}
While this works well for moderate numbers of clients, each thread consumes system resources. For high-concurrency scenarios, async I/O is more efficient.
Example: Async TCP Server with Tokio
Tokio tasks consume only ~1KB versus ~2MB for OS threads, enabling 10K+ concurrent connections. The tokio::spawn function creates lightweight tasks, while each .await yields control to the runtime scheduler. Cooperative multitasking ensures slow clients never block others.
#![allow(unused)]
fn main() {
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main] // Tasks ~1KB vs threads ~2MB, handles 10K+ connections
async fn async_echo_server() -> tokio::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Async server listening on port 8080");
loop {
let (socket, addr) = listener.accept().await?; // Async accept
println!("New connection from {}", addr);
tokio::spawn(async move { // Cheap async task
if let Err(e) = handle_connection(socket).await {
eprintln!("Error handling {}: {}", addr, e);
}
});
}
}
async fn handle_connection(mut socket: TcpStream) -> tokio::io::Result<()> {
let mut buffer = vec![0; 1024];
loop {
let n = socket.read(&mut buffer).await?; // Yields to other tasks
if n == 0 { return Ok(()); } // Connection closed
socket.write_all(&buffer[..n]).await?;
}
}
async_echo_server().await?; // Handles 10K+ concurrent connections
}
The key advantage here is that await points yield control to the runtime, allowing other tasks to make progress. This means one slow client doesn’t block others.
Example: Line-based Protocol Server
Uses BufReader wrapping the socket for efficient buffered I/O, with read_line() parsing newline-delimited commands common in HTTP, SMTP, and Redis protocols. The into_split() method separates read and write halves for concurrent bidirectional communication in text-based protocols.
#![allow(unused)]
fn main() {
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
async fn line_based_server() -> tokio::io::Result<()> { // SMTP/FTP style protocols
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Line-based server listening on port 8080");
loop {
let (socket, addr) = listener.accept().await?;
println!("Connection from {}", addr);
tokio::spawn(async move {
let (reader, mut writer) = socket.into_split();
let mut reader = BufReader::new(reader); // Efficient buffered I/O
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => { println!("Client {} disconnected", addr); break; }
Ok(_) => {
let response = process_command(&line);
if writer.write_all(response.as_bytes()).await.is_err() { break; }
}
Err(_) => break,
}
}
});
}
}
fn process_command(line: &str) -> String {
let line = line.trim();
match line.to_uppercase().as_str() {
"HELLO" => "WORLD\n".to_string(),
"QUIT" => "BYE\n".to_string(),
_ => format!("ECHO: {}\n", line),
}
}
line_based_server().await?; // Text protocol: HELLO → WORLD
}
This pattern is extremely common in network programming. By reading line-by-line, you can implement simple command-response protocols efficiently.
Example: TCP Client
Demonstrates the fundamental connect-send-receive pattern using TcpStream::connect() for establishing connections. The write_all method guarantees complete data transmission, while read returns available bytes. For interactive clients requiring simultaneous input and output, split into read/write halves using into_split().
#![allow(unused)]
fn main() {
use tokio::net::TcpStream;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn tcp_client_example() -> tokio::io::Result<()> {
let mut stream = TcpStream::connect("127.0.0.1:8080").await?;
println!("Connected to server");
stream.write_all(b"Hello, Server!\n").await?; // Send
let mut buffer = vec![0; 1024];
let n = stream.read(&mut buffer).await?; // Receive
println!("Received: {}", String::from_utf8_lossy(&buffer[..n]));
Ok(())
}
tcp_client_example().await?; // Simple send/receive TCP client
}
For more complex clients, you might want to separate reading and writing:
#![allow(unused)]
fn main() {
use tokio::net::TcpStream;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
async fn interactive_client() -> tokio::io::Result<()> { // Concurrent read/write
let stream = TcpStream::connect("127.0.0.1:8080").await?;
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let read_handle = tokio::spawn(async move { // Incoming messages
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => print!("Server: {}", line),
Err(_) => break,
}
}
});
let write_handle = tokio::spawn(async move { // User input
use tokio::io::{stdin, AsyncBufReadExt, BufReader};
let stdin = BufReader::new(stdin());
let mut lines = stdin.lines();
while let Ok(Some(line)) = lines.next_line().await {
if writer.write_all(format!("{}\n", line).as_bytes()).await.is_err() {
break;
}
}
});
// Wait for either task to finish
tokio::select! {
_ = read_handle => println!("Read task finished"),
_ = write_handle => println!("Write task finished"),
}
Ok(())
}
interactive_client().await?; // Concurrent read/write from stdin
}
This pattern—splitting reading and writing into separate tasks—is very powerful for building responsive network clients.
Example: Connection Pooling
Reuses established connections to avoid expensive TCP handshakes and TLS negotiations on every request. The RAII wrapper PooledConnection implements Drop to automatically return connections to the pool. A Mutex-protected VecDeque manages available connections. For production, use deadpool or bb8.
#![allow(unused)]
fn main() {
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use std::sync::Arc;
use std::collections::VecDeque;
struct ConnectionPool { // Production: use deadpool or bb8
available: Arc<Mutex<VecDeque<TcpStream>>>,
address: String,
max_size: usize,
}
impl ConnectionPool {
fn new(address: String, max_size: usize) -> Self {
ConnectionPool {
available: Arc::new(Mutex::new(VecDeque::new())),
address,
max_size,
}
}
async fn acquire(&self) -> tokio::io::Result<PooledConnection> {
let mut pool = self.available.lock().await;
if let Some(stream) = pool.pop_front() { // Reuse existing
return Ok(PooledConnection { stream: Some(stream), pool: self.available.clone() });
}
drop(pool); // Release lock before async connect
let stream = TcpStream::connect(&self.address).await?;
Ok(PooledConnection {
stream: Some(stream),
pool: self.available.clone(),
})
}
}
/// RAII wrapper that returns connection to pool on drop
struct PooledConnection {
stream: Option<TcpStream>,
pool: Arc<Mutex<VecDeque<TcpStream>>>,
}
impl Drop for PooledConnection {
fn drop(&mut self) {
if let Some(stream) = self.stream.take() {
let pool = self.pool.clone();
tokio::spawn(async move {
pool.lock().await.push_back(stream);
});
}
}
}
impl std::ops::Deref for PooledConnection {
type Target = TcpStream;
fn deref(&self) -> &Self::Target {
self.stream.as_ref().unwrap()
}
}
impl std::ops::DerefMut for PooledConnection {
fn deref_mut(&mut self) -> &mut Self::Target {
self.stream.as_mut().unwrap()
}
}
let pool = ConnectionPool::new("127.0.0.1:8080".into(), 10); let conn = pool.acquire().await?;
}
Pattern 2: UDP Patterns
Problem: Need low-latency connectionless communication where packet loss acceptable. TCP handshake/ack overhead too high for real-time data.
Solution: Use tokio::net::UdpSocket. bind() on server.
Why It Matters: Lower latency than TCP (no handshake, no acks)—critical for gaming, VoIP. Essential for real-time where latest data > old data (position updates).
Use Cases: Gaming (player position/state updates), VoIP (audio packets), video streaming (RTP), DNS queries, service discovery (mDNS, SSDP), IoT sensor data, time synchronization (NTP), multicast notifications, DHCP, TFTP.
Example: UDP Echo Server Pattern
Connectionless protocol requiring no handshake or connection tracking—each datagram is independent with no guaranteed delivery or ordering. The recv_from method returns both received data and sender’s socket address, enabling send_to to reply directly. Simpler than TCP with no per-client state needed.
#![allow(unused)]
fn main() {
use tokio::net::UdpSocket;
use std::io;
async fn udp_echo_server() -> io::Result<()> { // Connectionless, no state
let socket = UdpSocket::bind("127.0.0.1:8080").await?;
println!("UDP server listening on port 8080");
let mut buffer = vec![0u8; 1024];
loop {
let (len, addr) = socket.recv_from(&mut buffer).await?; // Returns sender addr
println!("Received {} bytes from {}", len, addr);
socket.send_to(&buffer[..len], addr).await?; // Echo back
}
}
udp_echo_server().await?; // Connectionless, echoes datagrams
}
Notice how much simpler this is than TCP—no connection management, no accept loop. Each packet is independent.
Example: UDP Client
The connect() method sets a default destination address, allowing simpler send/recv calls instead of send_to/recv_from—note this doesn’t establish an actual connection since UDP is connectionless. Production clients must implement timeouts and retry logic since delivery is not guaranteed.
#![allow(unused)]
fn main() {
use tokio::net::UdpSocket;
async fn udp_client_example() -> tokio::io::Result<()> {
let socket = UdpSocket::bind("0.0.0.0:0").await?; // Any available port
socket.connect("127.0.0.1:8080").await?; // Sets default dest (no actual connection)
// Send a message
let message = b"Hello, UDP Server!";
socket.send(message).await?;
// Wait for a response
let mut buffer = vec![0u8; 1024];
let len = socket.recv(&mut buffer).await?;
println!("Received: {}", String::from_utf8_lossy(&buffer[..len]));
Ok(())
}
udp_client_example().await?; // Send datagram, receive response
}
Example: Broadcast and Multicast
Calling set_broadcast(true) enables sending datagrams to the broadcast address (255.255.255.255), reaching all hosts on the local network. Essential for service discovery protocols like mDNS, SSDP, and DHCP where clients find servers without knowing their addresses beforehand.
#![allow(unused)]
fn main() {
use tokio::net::UdpSocket;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
async fn udp_broadcast() -> tokio::io::Result<()> { // Service discovery
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.set_broadcast(true)?;
let broadcast_addr = SocketAddr::new( // 255.255.255.255 = all local hosts
IpAddr::V4(Ipv4Addr::new(255, 255, 255, 255)),
8080
);
let message = b"Service Discovery Request";
socket.send_to(message, broadcast_addr).await?;
println!("Broadcast sent");
Ok(())
}
async fn udp_broadcast_listener() -> tokio::io::Result<()> { // Receives broadcasts
let socket = UdpSocket::bind("0.0.0.0:8080").await?;
socket.set_broadcast(true)?;
let mut buffer = vec![0u8; 1024];
loop {
let (len, addr) = socket.recv_from(&mut buffer).await?;
println!("Broadcast from {}: {}",
addr,
String::from_utf8_lossy(&buffer[..len])
);
}
}
udp_broadcast().await?; // Broadcast discovery message to 255.255.255.255
}
Example: Reliable UDP Pattern
Adds application-layer reliability to UDP through timeouts and retry logic. The tokio::time::timeout() wrapper fails the operation if no response arrives within the specified duration. This pattern underpins DNS queries and forms the basis of QUIC. For complex requirements, use the quinn crate.
#![allow(unused)]
fn main() {
use tokio::net::UdpSocket;
use tokio::time::{timeout, Duration};
async fn reliable_udp_request( // DNS-style retry logic
socket: &UdpSocket,
message: &[u8],
server_addr: &str,
retries: usize,
) -> tokio::io::Result<Vec<u8>> {
let mut buffer = vec![0u8; 1024];
for attempt in 0..retries {
// Send the request
socket.send_to(message, server_addr).await?;
// Wait for response with timeout
match timeout(Duration::from_secs(2), socket.recv_from(&mut buffer)).await {
Ok(Ok((len, _addr))) => return Ok(buffer[..len].to_vec()), // Success
Ok(Err(e)) => return Err(e),
Err(_) => { println!("Attempt {} timed out", attempt + 1); continue; } // Retry
}
}
Err(tokio::io::Error::new(
tokio::io::ErrorKind::TimedOut,
"All retry attempts failed"
))
}
reliable_udp_request(&socket, b"data", "127.0.0.1:8080", 3).await?;
}
This pattern is the basis for protocols like QUIC and helps bridge the gap between UDP’s speed and TCP’s reliability.
Pattern 3: HTTP Client (reqwest)
Problem: Need to make HTTP requests with async I/O. Handle cookies, headers, redirects automatically.
Solution: Use reqwest::Client with built-in connection pool. Async/await API.
Why It Matters: HTTP ubiquitous for APIs—essential for microservices. reqwest production-ready (connection pooling, retries, cookie management).
Use Cases: REST API clients, web scraping, microservice communication, webhook consumers, OAuth authentication flows, file downloads, GraphQL clients, API testing/monitoring, service health checks, data synchronization.
Example: Basic HTTP Client Pattern
The reqwest crate provides a production-ready async HTTP client with automatic connection pooling, cookie handling, redirect following, and TLS support. The reqwest::get() function performs simple requests, while .json::<T>() deserializes JSON responses directly into Rust structs using serde.
#![allow(unused)]
fn main() {
use reqwest;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ApiResponse {
message: String,
status: String,
}
async fn simple_get_request() -> Result<(), Box<dyn std::error::Error>> {
let response = reqwest::get("https://httpbin.org/get").await?;
println!("Status: {}", response.status());
println!("Headers: {:#?}", response.headers());
// Read the response body as text
let body = response.text().await?;
println!("Body: {}", body);
Ok(())
}
async fn get_json() -> Result<(), Box<dyn std::error::Error>> { // Auto-deserialize JSON
let response = reqwest::get("https://api.example.com/data").await?.json::<ApiResponse>().await?;
println!("Response: {:?}", response);
Ok(())
}
simple_get_request().await?; // GET with headers, body, status
}
The .json() method automatically deserializes the response body using serde, making it very convenient for API interactions.
Example: POST Requests and Request Building
Uses the builder pattern for constructing requests: .json() automatically serializes structs via serde and sets Content-Type to application/json, while .form() creates URL-encoded form data. Reuse a single Client instance to benefit from its internal connection pool.
#![allow(unused)]
fn main() {
use reqwest::{Client, header};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Serialize)]
struct CreateUser {
username: String,
email: String,
}
#[derive(Deserialize, Debug)]
struct User {
id: u64,
username: String,
email: String,
}
async fn create_user() -> Result<(), Box<dyn std::error::Error>> { // POST JSON
let client = Client::new();
let new_user = CreateUser { username: "alice".to_string(), email: "alice@example.com".to_string() };
let response = client.post("https://api.example.com/users").json(&new_user).send().await?;
let created_user: User = response.json().await?;
println!("Created user: {:?}", created_user);
Ok(())
}
async fn post_form() -> Result<(), Box<dyn std::error::Error>> { // URL-encoded form
let client = Client::new();
let mut form_data = HashMap::new();
form_data.insert("username", "bob");
form_data.insert("password", "secret123");
let response = client.post("https://example.com/login").form(&form_data).send().await?;
println!("Login status: {}", response.status());
Ok(())
}
create_user().await?; // POST JSON, deserialize response
}
Example: Request Headers and Authentication
Use .header() for per-request authentication headers, or configure default_headers() on Client::builder() to apply headers to all requests automatically. Supports Bearer tokens, HTTP Basic authentication, and API keys. The HeaderMap type provides type-safe header name constants.
#![allow(unused)]
fn main() {
use reqwest::{Client, header};
async fn request_with_auth() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let response = client.get("https://api.example.com/protected")
.header(header::AUTHORIZATION, "Bearer YOUR_API_TOKEN")
.header(header::USER_AGENT, "MyApp/1.0")
.send().await?;
println!("Response: {}", response.text().await?);
Ok(())
}
async fn client_with_defaults() -> Result<(), Box<dyn std::error::Error>> {
let mut headers = header::HeaderMap::new();
headers.insert(header::AUTHORIZATION, header::HeaderValue::from_static("Bearer YOUR_API_TOKEN"));
headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_static("application/json"));
let client = Client::builder()
.default_headers(headers) // All requests include these
.timeout(std::time::Duration::from_secs(30))
.build()?;
let _response = client.get("https://api.example.com/data").send().await?;
Ok(())
}
client_with_defaults().await?; // All requests include Bearer token
}
Example: Error Handling and Retries
Implements intelligent retry logic: exponential backoff for 5xx server errors allows services time to recover, fixed delays for 429 rate-limit responses respect throttling, and immediate failure on 4xx client errors avoids wasting retries. For production, consider reqwest-retry or tower.
#![allow(unused)]
fn main() {
use reqwest::{Client, StatusCode};
use tokio::time::{sleep, Duration};
async fn request_with_retry( // Exponential backoff
client: &Client,
url: &str,
max_retries: u32,
) -> Result<String, Box<dyn std::error::Error>> {
let mut attempts = 0;
loop {
attempts += 1;
match client.get(url).send().await {
Ok(response) => {
match response.status() {
StatusCode::OK => {
return Ok(response.text().await?);
}
StatusCode::TOO_MANY_REQUESTS => { // Rate limited
if attempts >= max_retries { return Err("Max retries exceeded".into()); }
sleep(Duration::from_secs(5)).await;
continue;
}
status if status.is_server_error() => { // 5xx - backoff
if attempts >= max_retries { return Err(format!("Server error: {}", status).into()); }
sleep(Duration::from_secs(2u64.pow(attempts))).await;
continue;
}
status => return Err(format!("HTTP error: {}", status).into()), // 4xx - no retry
}
}
Err(e) => {
if attempts >= max_retries {
return Err(e.into());
}
println!("Request failed: {}, retrying...", e);
sleep(Duration::from_secs(2)).await;
continue;
}
}
}
}
request_with_retry(&client, "https://api.com/data", 3).await?;
}
This pattern—exponential backoff with retry limits—is essential for building resilient network clients.
Example: Downloading Files
Streams large files chunk-by-chunk using bytes_stream() from futures_util::StreamExt, avoiding loading entire files into memory. The content_length() method retrieves file size for progress calculations. Each chunk is written immediately using async file I/O, keeping memory usage constant.
#![allow(unused)]
fn main() {
use reqwest::Client;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use futures_util::StreamExt;
async fn download_file( // Streaming download with progress
url: &str,
output_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let response = client.get(url).send().await?;
let total_size = response.content_length().unwrap_or(0);
let mut file = File::create(output_path).await?;
let mut downloaded = 0u64;
let mut stream = response.bytes_stream(); // Chunk-by-chunk
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
downloaded += chunk.len() as u64;
if total_size > 0 {
let percent = (downloaded as f64 / total_size as f64) * 100.0;
print!("\rProgress: {:.2}%", percent);
}
}
println!("\nDownload complete!");
Ok(())
}
download_file("https://example.com/file.zip", "file.zip").await?;
}
Pattern 4: HTTP Server (axum, actix-web)
Problem: Need HTTP server with routing, middleware, shared state. Handle concurrent requests safely.
Solution: Use axum Router for routing. State extractor for shared data (Arc for thread-safety).
Why It Matters: Web servers are core infrastructure—REST APIs, microservices, dashboards. axum built on tokio/hyper (100K+ req/s).
Use Cases: REST APIs, web applications, microservices, GraphQL servers (with async-graphql), webhook receivers, admin dashboards, file upload services, proxy/API gateway, authentication services, monitoring endpoints.
Example: axum Server Pattern
Type-safe framework built on tokio and hyper with declarative routing via the Router builder. Extractors like Path, Query, and Json automatically parse and validate request data at compile time—invalid input returns HTTP 400 before handler code executes.
#![allow(unused)]
fn main() {
use axum::{
routing::{get, post},
Router,
Json,
extract::{Path, Query},
response::IntoResponse,
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
#[derive(Serialize, Deserialize)]
struct User {
id: u64,
username: String,
email: String,
}
#[derive(Deserialize)]
struct CreateUserRequest {
username: String,
email: String,
}
#[derive(Deserialize)]
struct ListQuery {
page: Option<u32>,
per_page: Option<u32>,
}
#[tokio::main]
async fn basic_axum_server() {
let app = Router::new()
.route("/", get(root_handler))
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user));
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
axum::Server::bind(&addr).serve(app.into_make_service()).await.unwrap();
}
async fn root_handler() -> &'static str { "Hello, World!" }
async fn list_users(Query(params): Query<ListQuery>) -> Json<Vec<User>> {
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(10);
let users = vec![ // Real app: fetch from database
User {
id: 1,
username: "alice".to_string(),
email: "alice@example.com".to_string(),
},
User {
id: 2,
username: "bob".to_string(),
email: "bob@example.com".to_string(),
},
];
Json(users)
}
async fn get_user(Path(user_id): Path<u64>) -> Result<Json<User>, StatusCode> {
if user_id == 1 { // Real app: database lookup
Ok(Json(User {
id: 1,
username: "alice".to_string(),
email: "alice@example.com".to_string(),
}))
} else {
Err(StatusCode::NOT_FOUND)
}
}
async fn create_user(Json(payload): Json<CreateUserRequest>) -> (StatusCode, Json<User>) {
let user = User { id: 42, username: payload.username, email: payload.email }; // ID from DB
(StatusCode::CREATED, Json(user))
}
basic_axum_server(); // Routes: GET /, GET/POST /users, GET /users/:id
}
Notice how axum uses extractors (like Path, Query, Json) to parse and validate request data at compile time. If the types don’t match, you get a compile error.
Example: Shared State in axum
The State extractor provides access to application-wide data across all handlers, registered via .with_state(). For mutable shared state, wrap data in Arc<RwLock<T>>—Arc enables shared ownership across async tasks, while RwLock allows concurrent reads with exclusive writes.
#![allow(unused)]
fn main() {
use axum::{
routing::get,
Router,
extract::State,
Json,
};
use std::sync::Arc;
use tokio::sync::RwLock;
use serde::Serialize;
#[derive(Clone)]
struct AppState {
db: Arc<RwLock<Database>>, // Arc: shared, RwLock: multi-reader/single-writer
config: Arc<Config>,
}
struct Database {
users: Vec<User>,
}
struct Config {
max_users: usize,
}
#[derive(Serialize, Clone)]
struct User {
id: u64,
name: String,
}
#[tokio::main]
async fn stateful_server() {
let state = AppState {
db: Arc::new(RwLock::new(Database {
users: vec![],
})),
config: Arc::new(Config {
max_users: 1000,
}),
};
let app = Router::new()
.route("/users", get(get_all_users))
.with_state(state);
// Run server...
}
async fn get_all_users(State(state): State<AppState>) -> Json<Vec<User>> {
let db = state.db.read().await; // Read lock
Json(db.users.clone()) // Real app: paginate
}
stateful_server(); // Shared Arc<RwLock<Database>> across handlers
}
The State extractor ensures every handler has access to the application state without global variables.
Example: Middleware in axum
Middleware wraps handlers to add cross-cutting concerns like logging, authentication, and CORS headers. The next.run(request) call invokes the next middleware or handler in the chain, enabling pre- and post-processing. Tower’s ecosystem provides production-ready middleware for rate limiting and tracing.
#![allow(unused)]
fn main() {
use axum::{
Router,
routing::get,
middleware::{self, Next},
response::Response,
http::Request,
};
use std::time::Instant;
#[tokio::main]
async fn middleware_example() {
let app = Router::new()
.route("/", get(|| async { "Hello!" }))
.layer(middleware::from_fn(timing_middleware)) // All routes
.layer(middleware::from_fn(auth_middleware));
}
async fn timing_middleware<B>( // Logs request duration
request: Request<B>,
next: Next<B>,
) -> Response {
let start = Instant::now();
let uri = request.uri().clone();
let response = next.run(request).await;
let elapsed = start.elapsed();
println!("{} took {:?}", uri, elapsed);
response
}
async fn auth_middleware<B>(request: Request<B>, next: Next<B>) -> Response {
if let Some(auth) = request.headers().get("authorization") {
if auth.to_str().unwrap_or("").starts_with("Bearer ") {
return next.run(request).await; // Valid auth
}
}
Response::builder().status(401).body("Unauthorized".into()).unwrap()
}
middleware_example(); // Timing + auth middleware on all routes
}
Middleware composes nicely, allowing you to build complex request processing pipelines.
Example: actix-web Server
One of the fastest Rust web frameworks, using an actor-based architecture with thread-per-core design for optimal CPU cache locality. Routes are defined declaratively with web::get() and web::post(). Choose actix-web for maximum performance; prefer axum for tower middleware compatibility.
#![allow(unused)]
fn main() {
use actix_web::{web, App, HttpServer, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct User {
id: u64,
name: String,
}
#[actix_web::main]
async fn actix_server() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(index))
.route("/users", web::get().to(get_users))
.route("/users", web::post().to(create_user))
.route("/users/{id}", web::get().to(get_user))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
async fn index() -> impl Responder {
HttpResponse::Ok().body("Hello from actix-web!")
}
async fn get_users() -> impl Responder {
let users = vec![
User { id: 1, name: "Alice".to_string() },
User { id: 2, name: "Bob".to_string() },
];
HttpResponse::Ok().json(users)
}
async fn create_user(user: web::Json<User>) -> impl Responder {
println!("Creating user: {}", user.name);
HttpResponse::Created().json(user.into_inner())
}
async fn get_user(path: web::Path<u64>) -> impl Responder {
let user_id = path.into_inner();
if user_id == 1 {
HttpResponse::Ok().json(User {
id: 1,
name: "Alice".to_string(),
})
} else {
HttpResponse::NotFound().finish()
}
}
actix_server(); // Fast actor-based HTTP server
}
actix-web is slightly more imperative in style compared to axum’s declarative approach, but both are excellent choices.
Pattern 5: WebSocket Patterns
Problem: Need full-duplex real-time bidirectional communication. HTTP request-response inadequate for live updates.
Solution: Use tokio-tungstenite for WebSocket (or axum/actix WebSocket support). HTTP upgrade to WebSocket.
Why It Matters: Real-time applications require bidirectional push—can’t rely on polling. WebSocket single persistent connection vs HTTP polling overhead (100 req/s vs 1 connection).
Use Cases: Chat applications (real-time messages), live notifications (alerts, updates), collaborative editing (Google Docs-style), stock tickers (price updates), gaming (multiplayer state sync), dashboard updates (metrics, logs), IoT device control, video streaming signaling.
Example: WebSocket Broadcast Pattern
Implements full-duplex real-time chat using tokio’s broadcast channel for fan-out messaging to all connected clients. The WebSocket connection is split into separate read and write tasks via socket.split(), enabling concurrent bidirectional communication. The tokio::select! macro monitors both tasks.
#![allow(unused)]
fn main() {
use axum::{
routing::get,
Router,
extract::{
ws::{WebSocket, WebSocketUpgrade, Message},
State,
},
response::IntoResponse,
};
use std::sync::Arc;
use tokio::sync::broadcast;
#[derive(Clone)]
struct AppState {
tx: broadcast::Sender<String>, // Fan-out to all clients
}
#[tokio::main]
async fn websocket_server() {
let (tx, _rx) = broadcast::channel(100);
let state = AppState { tx };
let app = Router::new().route("/ws", get(websocket_handler)).with_state(state);
axum::Server::bind(&"127.0.0.1:3000".parse().unwrap()).serve(app.into_make_service()).await.unwrap();
}
async fn websocket_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_socket(socket, state)) // HTTP → WebSocket upgrade
}
async fn handle_socket(socket: WebSocket, state: AppState) {
let (mut sender, mut receiver) = socket.split(); // Separate read/write
let mut rx = state.tx.subscribe();
let mut send_task = tokio::spawn(async move { // Broadcast → client
while let Ok(msg) = rx.recv().await {
if sender.send(Message::Text(msg)).await.is_err() { break; }
}
});
let tx = state.tx.clone();
let mut recv_task = tokio::spawn(async move { // Client → broadcast
while let Some(Ok(Message::Text(text))) = receiver.next().await {
let _ = tx.send(text);
}
});
tokio::select! { // Clean up when either task finishes
_ = (&mut send_task) => recv_task.abort(),
_ = (&mut recv_task) => send_task.abort(),
}
}
websocket_server(); // Broadcast chat: all clients receive all messages
}
This creates a simple chat server where all messages are broadcast to all connected clients. Each client gets two tasks: one for receiving broadcasts and one for sending messages.
Example: WebSocket Client
Uses tokio-tungstenite for async WebSocket connections. The connect_async() function upgrades HTTP to WebSocket protocol. Split the stream into read/write halves for concurrent operations. Always send a Close frame for graceful disconnection to notify the server properly.
#![allow(unused)]
fn main() {
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use futures_util::{StreamExt, SinkExt};
async fn websocket_client() -> Result<(), Box<dyn std::error::Error>> {
let (ws_stream, _) = connect_async("ws://127.0.0.1:3000/ws").await?;
let (mut write, mut read) = ws_stream.split();
let read_handle = tokio::spawn(async move { // Handle incoming
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(text)) => println!("Received: {}", text),
Ok(Message::Close(_)) => break,
Err(_) => break,
_ => {}
}
}
});
for i in 0..5 { // Send messages
write.send(Message::Text(format!("Message {}", i))).await?;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
write.send(Message::Close(None)).await?; // Graceful close
read_handle.await?;
Ok(())
}
websocket_client().await?; // Connect, send 5 messages, close
}
Example: Room-based WebSocket Pattern
Implements multiple chat rooms with independent broadcast channels. A RwLock<HashMap> enables concurrent reads across rooms while allowing exclusive writes when users join or leave. Rooms are created on-demand when the first user joins and garbage-collected when the last user leaves.
#![allow(unused)]
fn main() {
use axum::extract::ws::{WebSocket, Message};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{RwLock, broadcast};
type RoomId = String;
type UserId = String;
struct ChatServer {
rooms: Arc<RwLock<HashMap<RoomId, Room>>>,
}
struct Room {
tx: broadcast::Sender<ChatMessage>, // Per-room broadcast
users: HashMap<UserId, UserInfo>,
}
struct UserInfo {
username: String,
}
#[derive(Clone)]
struct ChatMessage {
user_id: UserId,
username: String,
content: String,
}
impl ChatServer {
fn new() -> Self {
ChatServer {
rooms: Arc::new(RwLock::new(HashMap::new())),
}
}
async fn join_room(&self, room_id: RoomId, user_id: UserId, username: String) -> broadcast::Receiver<ChatMessage> {
let mut rooms = self.rooms.write().await;
let room = rooms.entry(room_id.clone()).or_insert_with(|| {
let (tx, _) = broadcast::channel(100);
Room {
tx,
users: HashMap::new(),
}
});
room.users.insert(user_id, UserInfo { username });
room.tx.subscribe()
}
async fn send_message(&self, room_id: &RoomId, msg: ChatMessage) {
let rooms = self.rooms.read().await;
if let Some(room) = rooms.get(room_id) {
let _ = room.tx.send(msg);
}
}
async fn leave_room(&self, room_id: &RoomId, user_id: &UserId) {
let mut rooms = self.rooms.write().await;
if let Some(room) = rooms.get_mut(room_id) {
room.users.remove(user_id);
if room.users.is_empty() { rooms.remove(room_id); } // GC empty rooms
}
}
}
let server = ChatServer::new(); server.join_room("room1", "user1", "Alice").await;
}
This pattern allows users to join specific rooms and only receive messages from those rooms, which is much more scalable than broadcasting everything to everyone.
Example: Handling Ping/Pong for Keep-Alive
Ping/pong frames detect dead connections caused by NAT timeouts or crashes. Send Ping every 30 seconds; a missing Pong response indicates a dead connection that should be cleaned up. Most WebSocket libraries automatically respond to incoming Ping frames with Pong.
#![allow(unused)]
fn main() {
use tokio::time::{interval, Duration};
use axum::extract::ws::{WebSocket, Message};
async fn websocket_with_keepalive(mut socket: WebSocket) {
let mut ping_interval = interval(Duration::from_secs(30));
loop {
tokio::select! {
_ = ping_interval.tick() => {
// Send ping
if socket.send(Message::Ping(vec![])).await.is_err() {
break;
}
}
msg = socket.recv() => {
match msg {
Some(Ok(Message::Pong(_))) => {
// Client is alive
}
Some(Ok(Message::Text(text))) => {
// Handle message
println!("Received: {}", text);
}
Some(Ok(Message::Close(_))) | None => {
break;
}
Some(Err(e)) => {
eprintln!("Error: {}", e);
break;
}
_ => {}
}
}
}
}
println!("WebSocket connection closed");
}
websocket_with_keepalive(socket).await; // Ping every 30s to detect dead connections
}
This ensures you detect and clean up dead connections promptly.
Summary
This chapter covered network programming patterns:
- TCP Server/Client: Async TcpListener/TcpStream, tokio::spawn per connection, BufReader for protocols
- UDP Patterns: UdpSocket, send_to/recv_from, broadcast/multicast, connectionless communication
- HTTP Client (reqwest): Client with connection pool, JSON serialization, cookies, timeouts, retries
- HTTP Server (axum): Router, extractors (Json, Query, Path), middleware, shared State, type-safe
- WebSocket Patterns: Full-duplex real-time, split read/write, broadcast channel, ping/pong keepalive
Key Takeaways:
- Async I/O solves C10K problem—single thread handles 10K+ connections
- TCP reliable but higher latency; UDP fast but lossy
- reqwest industry standard for HTTP clients (connection pooling, retries)
- axum type-safe extractors prevent runtime bugs (compile-time validation)
- WebSocket enables server-initiated push (vs HTTP polling overhead)
Protocol Selection:
- TCP: Reliable ordered delivery (HTTP, SSH, databases)
- UDP: Low-latency real-time (gaming, VoIP, DNS)
- HTTP: Request-response APIs (REST, microservices)
- WebSocket: Bidirectional real-time (chat, live updates)
Performance Patterns:
- Connection pooling (reqwest Client, database pools)
- Buffering (BufReader for line protocols)
- Async spawning (tokio::spawn per connection)
- Graceful shutdown (CancellationToken)
- Backpressure (bounded channels)
Error Handling:
- Network errors common—design for failure
- Timeouts essential (prevent hanging on dead connections)
- Graceful degradation (retry with backoff)
- Connection cleanup (tokio::select! for cancellation)
- Validate input (malicious clients)
Best Practices:
- Always set timeouts on network operations
- Handle connection errors gracefully
- Use connection pooling for performance
- Implement graceful shutdown
- Buffer I/O operations (avoid byte-by-byte)
- Validate and sanitize all input
- Use TLS for sensitive data
Common Use Cases:
- Chat server: WebSocket with broadcast channel
- REST API: axum with JSON extractors
- Game server: UDP for position updates + TCP for critical events
- Microservices: reqwest client + axum server
- Proxy: TCP forwarding with tokio
- Live dashboard: WebSocket for real-time metrics
Tools:
- tokio: Async runtime, TcpListener/TcpStream/UdpSocket
- reqwest: HTTP client with connection pooling
- axum: HTTP server framework (type-safe, tokio-based)
- tokio-tungstenite: WebSocket implementation
- tower: Middleware for HTTP services
- hyper: Low-level HTTP library (foundation for axum)