Introduction
Rust is increasingly becoming the language of choice for building high-performance web services. Axum, built on Tokio, provides a minimal and composable framework for web applications with strong type safety and memory safety guarantees.
This guide covers building production-ready web services using Axum, from basics to advanced patterns.
Why Rust for Web Services?
Performance Benefits
Request latency comparison (p99):
- Node.js: 25-50ms
- Python: 100-200ms
- Go: 10-20ms
- Rust (Axum): 5-15ms
Memory usage per 1000 concurrent connections:
- Node.js: 200-300 MB
- Python: 400-600 MB
- Go: 50-100 MB
- Rust: 20-50 MB
Safety Benefits
Rust eliminates entire categories of bugs:
- Memory leaks: Impossible by design
- Null pointer dereferences: No null type
- Data races: Compile-time prevention
- Buffer overflows: Automatic bounds checking
Axum Framework Overview
What is Axum?
Axum = Tokio + Tower ecosystem
- Tokio: Async runtime for concurrency
- Tower: Middleware and service abstraction
- Axum: Web framework on top
Architecture:
Router -> Middleware -> Handler -> Response
Installation
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tower = "0.4"
tower-http = { version = "0.5", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
Hello World Application
Basic Server
use axum::{
routing::get,
Router,
};
use std::net::SocketAddr;
#[tokio::main]
async fn main() {
// Build router with routes
let app = Router::new()
.route("/", get(hello_world));
// Bind and serve
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
println!("Listening on {}", addr);
axum::serve(listener, app).await.unwrap();
}
async fn hello_world() -> &'static str {
"Hello, World!"
}
Run the Server
cargo run
# Listening on 127.0.0.1:3000
# In another terminal:
curl http://127.0.0.1:3000
# Hello, World!
Routing & Handlers
Route Patterns
use axum::routing::{get, post, put, delete};
let app = Router::new()
// Simple routes
.route("/", get(index))
.route("/users", get(list_users).post(create_user))
// Path parameters
.route("/users/:id", get(get_user).put(update_user))
// Nested routes
.nest("/api", api_routes())
// Fallback for 404
.fallback(not_found);
async fn index() -> &'static str {
"Welcome"
}
async fn list_users() -> &'static str {
"List of users"
}
async fn get_user(Path(id): Path<i32>) -> String {
format!("User: {}", id)
}
Extract Types from Requests
use axum::{
extract::{Path, Query, Json},
http::StatusCode,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct ListQuery {
limit: Option<i32>,
offset: Option<i32>,
}
#[derive(Serialize, Deserialize)]
struct User {
id: i32,
name: String,
email: String,
}
async fn list_users_with_pagination(
Query(params): Query<ListQuery>,
) -> Json<Vec<User>> {
let limit = params.limit.unwrap_or(10);
let offset = params.offset.unwrap_or(0);
Json(vec![
User {
id: 1,
name: "Alice".to_string(),
email: "[email protected]".to_string(),
},
])
}
async fn create_user(
Json(payload): Json<User>,
) -> (StatusCode, Json<User>) {
(StatusCode::CREATED, Json(payload))
}
State Management
Application State
use axum::extract::State;
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
db: Arc<Database>,
cache: Arc<Cache>,
}
let state = AppState {
db: Arc::new(Database::new()),
cache: Arc::new(Cache::new()),
};
let app = Router::new()
.route("/data", get(get_data))
.with_state(state);
async fn get_data(
State(state): State<AppState>,
) -> String {
format!("DB: {:?}, Cache: {:?}", state.db, state.cache)
}
Database Connection Pool
use sqlx::postgres::PgPool;
#[derive(Clone)]
struct AppState {
pool: PgPool,
}
#[tokio::main]
async fn main() {
let pool = PgPool::connect("postgres://localhost/mydb")
.await
.expect("Failed to create pool");
let state = AppState { pool };
let app = Router::new()
.route("/users/:id", get(get_user_from_db))
.with_state(state);
// ... serve app
}
async fn get_user_from_db(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> Json<User> {
let user = sqlx::query_as::<_, User>(
"SELECT id, name, email FROM users WHERE id = $1"
)
.bind(id)
.fetch_one(&state.pool)
.await
.unwrap();
Json(user)
}
Error Handling
Custom Error Type
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
#[derive(Debug)]
pub enum ApiError {
NotFound,
BadRequest(String),
InternalError,
Unauthorized,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "Resource not found"),
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, &msg),
ApiError::InternalError => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
};
let body = Json(json!({
"error": error_message
}));
(status, body).into_response()
}
}
async fn get_user(
Path(id): Path<i32>,
) -> Result<Json<User>, ApiError> {
if id < 0 {
return Err(ApiError::BadRequest("ID must be positive".to_string()));
}
// Try to find user
let user = find_user(id)
.await
.ok_or(ApiError::NotFound)?;
Ok(Json(user))
}
Middleware
Built-in Middleware
use tower_http::{
trace::{TraceLayer, DefaultMakeSpan},
cors::CorsLayer,
};
use std::time::Duration;
let app = Router::new()
.route("/", get(handler))
.layer(TraceLayer::new_for_http())
.layer(
CorsLayer::permissive()
);
Custom Middleware
use tower::middleware::Next;
use http::Request;
async fn timing_middleware<B>(
req: Request<B>,
next: Next,
) -> impl IntoResponse {
let start = Instant::now();
let response = next.run(req).await;
let elapsed = start.elapsed();
println!("Request took: {:?}", elapsed);
response
}
let app = Router::new()
.route("/", get(handler))
.layer(axum::middleware::from_fn(timing_middleware));
Real-World Example: Todo API
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post, put, delete},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone, Serialize, Deserialize)]
struct Todo {
id: u32,
title: String,
completed: bool,
}
#[derive(Clone)]
struct AppState {
todos: Arc<RwLock<Vec<Todo>>>,
}
#[tokio::main]
async fn main() {
let state = AppState {
todos: Arc::new(RwLock::new(vec![])),
};
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/:id", get(get_todo).put(update_todo).delete(delete_todo))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn list_todos(State(state): State<AppState>) -> Json<Vec<Todo>> {
let todos = state.todos.read().await;
Json(todos.clone())
}
async fn create_todo(
State(state): State<AppState>,
Json(mut todo): Json<Todo>,
) -> (StatusCode, Json<Todo>) {
todo.id = rand::random();
state.todos.write().await.push(todo.clone());
(StatusCode::CREATED, Json(todo))
}
async fn get_todo(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<Todo>, StatusCode> {
let todos = state.todos.read().await;
todos
.iter()
.find(|t| t.id == id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
async fn update_todo(
State(state): State<AppState>,
Path(id): Path<u32>,
Json(updated): Json<Todo>,
) -> StatusCode {
let mut todos = state.todos.write().await;
if let Some(todo) = todos.iter_mut().find(|t| t.id == id) {
*todo = updated;
StatusCode::OK
} else {
StatusCode::NOT_FOUND
}
}
async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> StatusCode {
let mut todos = state.todos.write().await;
if todos.iter().position(|t| t.id == id).is_some() {
todos.retain(|t| t.id != id);
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
Performance Optimization
Connection Pooling
// Use connection pools, not individual connections
let pool = PgPool::builder()
.max_connections(50)
.build("postgres://localhost/mydb")
.await?;
Caching
use std::sync::Arc;
use std::collections::HashMap;
#[derive(Clone)]
struct AppState {
cache: Arc<RwLock<HashMap<String, String>>>,
}
async fn get_with_cache(
State(state): State<AppState>,
Path(key): Path<String>,
) -> Result<String, StatusCode> {
// Check cache first
if let Some(value) = state.cache.read().await.get(&key) {
return Ok(value.clone());
}
// Cache miss - fetch from DB
let value = fetch_from_db(&key).await?;
state.cache.write().await.insert(key, value.clone());
Ok(value)
}
Testing
Unit Tests
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
use http::StatusCode;
#[tokio::test]
async fn test_hello_world() {
let response = hello_world().await;
assert_eq!(response, "Hello, World!");
}
#[tokio::test]
async fn test_create_todo() {
let state = AppState {
todos: Arc::new(RwLock::new(vec![])),
};
let todo = Todo {
id: 1,
title: "Test".to_string(),
completed: false,
};
let (status, _) = create_todo(State(state.clone()), Json(todo)).await;
assert_eq!(status, StatusCode::CREATED);
}
}
Deployment
Production Checklist
โ
Error handling for all endpoints
โ
Logging and tracing enabled
โ
Database connection pooling configured
โ
CORS policy defined
โ
Request validation implemented
โ
Rate limiting enabled
โ
Health check endpoint
โ
Graceful shutdown handling
โ
Environment configuration loaded from .env
โ
Tests passing (cargo test)
Glossary
- Axum: Ergonomic web framework built on Tokio
- Tokio: Async runtime for Rust
- Tower: Modular middleware and service abstractions
- Extract: Types that can be extracted from requests
- Middleware: Functions that process every request/response
- Handler: Async function that processes a request
Comments