Building web APIs in Rust has become increasingly popular due to the language’s focus on safety and performance. Unlike Python or Node.js frameworks that prioritize developer ergonomics at the cost of runtime overhead, Rust web frameworks offer something unique: the safety of a compiled language combined with the performance of systems programming, while still providing a pleasant developer experience.
This article explores two of the most powerful async web frameworks in the Rust ecosystem: Axum and Actix-web. We’ll examine their design philosophies, architectural patterns, and practical implementation details to help you choose the right tool for your use case.
Core Concepts & Architecture
Why Rust for Web APIs?
Before diving into frameworks, let’s understand why Rust is compelling for API development:
- Memory Safety Without Garbage Collection: Rust’s ownership system prevents entire classes of bugs (null pointer dereferencing, use-after-free) at compile time, not runtime.
- Fearless Concurrency: Rust’s type system makes it impossible to accidentally share mutable state across threads without proper synchronization.
- Performance: Zero-cost abstractions and no garbage collection overhead mean Rust APIs can handle more concurrent connections with fewer resources.
- Tooling: Cargo provides excellent dependency management, testing, and benchmarking out of the box.
Async/Await in Rust
Both Axum and Actix-web are built on async Rust, specifically using Tokio as the async runtime. Understanding async is crucial.
// Async functions return a Future that must be awaited
async fn fetch_user(id: u64) -> User {
// This is syntactic sugar for:
// fn fetch_user(id: u64) -> impl Future<Output = User>
database.get_user(id).await
}
#[tokio::main]
async fn main() {
let user = fetch_user(42).await;
println!("User: {:?}", user);
}
Key insight: Async in Rust is not a runtime like Node.js’s event loop. It’s a language feature that compiles to explicit state machines. This is why Rust async can be so efficientโthere’s minimal overhead compared to threads.
HTTP Request/Response Lifecycle
Both frameworks follow the same basic HTTP request flow:
Client Request
โ
[Router] - matches URL pattern
โ
[Middleware] - authentication, logging, CORS
โ
[Handler] - your business logic
โ
[Response] - JSON, HTML, or custom format
โ
Client Response
Axum: The Modern Choice
Axum is a newer framework (created in 2021 by the Tokio team) that emphasizes composability and type safety. It’s the official choice of the Tokio project.
Core Design Philosophy
Axum is built around three principles:
- Composable: Everything is modular; handlers, extractors, and middleware compose cleanly
- Ergonomic: Leverages Rust’s type system to provide excellent compile-time safety
- Performance: Minimal overhead; similar performance to Actix-web in benchmarks
Hello World with Axum
// Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
// src/main.rs
use axum::{
routing::get,
Router,
};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(hello));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app)
.await
.unwrap();
}
async fn hello() -> &'static str {
"Hello, World!"
}
Extractors: The Core Abstraction
Extractors are Axum’s most powerful feature. They automatically parse request data (path parameters, query strings, JSON bodies, headers) and inject them into your handlers.
use axum::{
extract::{Path, Query},
Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
struct User {
id: u64,
name: String,
email: String,
}
// Path extraction: /users/:id
async fn get_user(Path(id): Path<u64>) -> Json<User> {
let user = User {
id,
name: "Alice".to_string(),
email: "[email protected]".to_string(),
};
Json(user)
}
// Query extraction: /search?q=rust&limit=10
#[derive(Deserialize)]
struct SearchQuery {
q: String,
limit: Option<u32>,
}
async fn search(Query(params): Query<SearchQuery>) -> Json<Vec<String>> {
let results = vec![
format!("Result for: {}", params.q),
];
Json(results)
}
// JSON body extraction
async fn create_user(Json(payload): Json<User>) -> (axum::http::StatusCode, Json<User>) {
// Save to database...
(axum::http::StatusCode::CREATED, Json(payload))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:id", get(get_user))
.route("/search", get(search))
.route("/users", post(create_user));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Why extractors are powerful: Your function signature declares exactly what it needs. The compiler ensures you’ve handled all cases, and the framework handles parsing and error response generation automatically.
Middleware in Axum
Middleware in Axum is built on the tower ecosystem and follows a functional composition model.
use axum::{
middleware::Next,
http::Request,
response::Response,
};
use tower::Layer;
// Request/Response middleware
async fn logging_middleware(
req: Request<axum::body::Body>,
next: Next,
) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
let start = std::time::Instant::now();
let response = next.run(req).await;
let elapsed = start.elapsed();
println!("{} {} - {}ms", method, uri, elapsed.as_millis());
response
}
// Custom extractor middleware (runs per handler)
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
struct AuthToken(String);
#[async_trait::async_trait]
impl<S> FromRequestParts<S> for AuthToken
where
S: Send + Sync,
{
type Rejection = String;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.map(|v| AuthToken(v.to_string()))
.ok_or_else(|| "Missing Authorization header".to_string())
}
}
// Using middleware in handlers
async fn protected_route(AuthToken(token): AuthToken) -> String {
format!("Token: {}", token)
}
// Register global middleware
let app = Router::new()
.route("/protected", get(protected_route))
.layer(axum::middleware::from_fn(logging_middleware));
State Management in Axum
For sharing state across handlers (database connections, caches, configuration):
use axum::{
extract::State,
routing::get,
Router,
};
use std::sync::Arc;
// Your application state
#[derive(Clone)]
struct AppState {
db_connection: Arc<String>, // In reality, a database pool
config: Arc<AppConfig>,
}
#[derive(Clone)]
struct AppConfig {
environment: String,
}
async fn get_config(State(state): State<AppState>) -> String {
format!("Environment: {}", state.config.environment)
}
#[tokio::main]
async fn main() {
let state = AppState {
db_connection: Arc::new("postgres://...".to_string()),
config: Arc::new(AppConfig {
environment: "production".to_string(),
}),
};
let app = Router::new()
.route("/config", get(get_config))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Actix-web: The Performance Contender
Actix-web is the older, battle-tested framework (created in 2017) that emphasizes actor-based concurrency and raw performance. It’s used in production by many large organizations.
Core Design Philosophy
Actix-web is built on the Actor model where each request is handled by an actor:
- Actor-based: Uses Actix’s actor framework for internal concurrency
- Battle-tested: Proven in production for years
- Feature-rich: Includes built-in WebSocket support, file uploads, streaming
- Benchmarks: Consistently ranks highly in web framework benchmarks (TechEmpower)
Hello World with Actix-web
// Cargo.toml
[dependencies]
actix-web = "4"
actix-rt = "2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
// src/main.rs
use actix_web::{web, App, HttpServer, HttpResponse};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(hello))
})
.bind("127.0.0.1:3000")?
.run()
.await
}
async fn hello() -> HttpResponse {
HttpResponse::Ok().body("Hello, World!")
}
Handlers and Extractors in Actix-web
use actix_web::{web, App, HttpServer, HttpResponse};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
struct User {
id: u64,
name: String,
email: String,
}
// Path parameter extraction
async fn get_user(path: web::Path<u64>) -> HttpResponse {
let user_id = path.into_inner();
let user = User {
id: user_id,
name: "Alice".to_string(),
email: "[email protected]".to_string(),
};
HttpResponse::Ok().json(user)
}
// Query string extraction
#[derive(Deserialize)]
struct SearchQuery {
q: String,
limit: Option<u32>,
}
async fn search(query: web::Query<SearchQuery>) -> HttpResponse {
let results = vec![
format!("Result for: {}", query.q),
];
HttpResponse::Ok().json(results)
}
// JSON body extraction
async fn create_user(body: web::Json<User>) -> HttpResponse {
// Save to database...
HttpResponse::Created().json(body.into_inner())
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/users/{id}", web::get().to(get_user))
.route("/search", web::get().to(search))
.route("/users", web::post().to(create_user))
})
.bind("127.0.0.1:3000")?
.run()
.await
}
Middleware in Actix-web
Actix-web middleware follows a different model than Axumโmiddleware are factory functions that create middleware instances per request.
use actix_web::{
middleware::{Logger, DefaultHeaders},
web, App, HttpServer, HttpResponse,
};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
HttpServer::new(|| {
App::new()
.wrap(Logger::default())
.wrap(DefaultHeaders::new().add(("X-Version", "1.0")))
.route("/", web::get().to(index))
})
.bind("127.0.0.1:3000")?
.run()
.await
}
async fn index() -> HttpResponse {
HttpResponse::Ok().body("Logged request!")
}
State Management in Actix-web
use actix_web::{web, App, HttpServer, HttpResponse};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct AppState {
counter: Arc<Mutex<i32>>,
}
async fn increment(state: web::Data<AppState>) -> HttpResponse {
let mut counter = state.counter.lock().unwrap();
*counter += 1;
HttpResponse::Ok().json(serde_json::json!({
"count": *counter
}))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let state = web::Data::new(AppState {
counter: Arc::new(Mutex::new(0)),
});
HttpServer::new(move || {
App::new()
.app_data(state.clone())
.route("/increment", web::post().to(increment))
})
.bind("127.0.0.1:3000")?
.run()
.await
}
Architecture Patterns
Typical REST API Architecture
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Client (Browser/Mobile) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ HTTP
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ REST API Server (Rust) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Router Layer โ โ
โ โ /users/:id โ get_user handler โ โ
โ โ /users โ create_user handler โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Middleware Stack โ โ
โ โ 1. CORS โ โ
โ โ 2. Authentication/Authorization โ โ
โ โ 3. Request Logging โ โ
โ โ 4. Rate Limiting โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Handler Functions (Business Logic) โ โ
โ โ - Validate request โ โ
โ โ - Process data โ โ
โ โ - Return JSON response โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Database Queries
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PostgreSQL/MySQL โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Real-World Example: User Management API
Let’s build a complete example with Axum (Actix-web would be similar):
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{delete, get, post, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone, Serialize, Deserialize, Debug)]
struct User {
id: u64,
name: String,
email: String,
}
#[derive(Clone)]
struct AppState {
users: Arc<RwLock<Vec<User>>>,
}
// GET /users
async fn list_users(State(state): State<AppState>) -> Json<Vec<User>> {
let users = state.users.read().await;
Json(users.clone())
}
// GET /users/:id
async fn get_user(
Path(id): Path<u64>,
State(state): State<AppState>,
) -> Result<Json<User>, StatusCode> {
let users = state.users.read().await;
users
.iter()
.find(|u| u.id == id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
// POST /users
#[derive(Deserialize)]
struct CreateUserRequest {
name: String,
email: String,
}
async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserRequest>,
) -> (StatusCode, Json<User>) {
let mut users = state.users.write().await;
let new_user = User {
id: users.len() as u64 + 1,
name: payload.name,
email: payload.email,
};
users.push(new_user.clone());
(StatusCode::CREATED, Json(new_user))
}
// PUT /users/:id
async fn update_user(
Path(id): Path<u64>,
State(state): State<AppState>,
Json(payload): Json<CreateUserRequest>,
) -> Result<Json<User>, StatusCode> {
let mut users = state.users.write().await;
if let Some(user) = users.iter_mut().find(|u| u.id == id) {
user.name = payload.name;
user.email = payload.email;
Ok(Json(user.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}
// DELETE /users/:id
async fn delete_user(
Path(id): Path<u64>,
State(state): State<AppState>,
) -> StatusCode {
let mut users = state.users.write().await;
if let Some(pos) = users.iter().position(|u| u.id == id) {
users.remove(pos);
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[tokio::main]
async fn main() {
let state = AppState {
users: Arc::new(RwLock::new(vec![])),
};
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user).put(update_user).delete(delete_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("Server running on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
Common Pitfalls & Best Practices
1. Cloning State Unnecessarily
โ Bad: Cloning large structures on every request
async fn handler(State(state): State<AppState>) -> String {
// If AppState contains non-Arc fields, this clones everything
let state_copy = state.clone();
"response".to_string()
}
โ
Good: Use Arc for shared state
#[derive(Clone)]
struct AppState {
db: Arc<DatabasePool>,
config: Arc<Config>,
}
2. Blocking Operations in Async Context
โ Bad: Using blocking I/O in async handler
async fn handler() -> String {
let data = std::fs::read_to_string("file.txt").unwrap(); // Blocks thread!
data
}
โ Good: Use async I/O
async fn handler() -> String {
let data = tokio::fs::read_to_string("file.txt")
.await
.unwrap();
data
}
3. Error Handling
โ Bad: Unwrapping in handlers
async fn handler() -> String {
let value = some_operation().unwrap(); // Panics on error!
value
}
โ Good: Proper error handling with custom error types
use axum::response::{IntoResponse, Response};
#[derive(Debug)]
enum AppError {
NotFound,
DatabaseError(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
AppError::NotFound => StatusCode::NOT_FOUND.into_response(),
AppError::DatabaseError(msg) => {
(StatusCode::INTERNAL_SERVER_ERROR, msg).into_response()
}
}
}
}
async fn handler() -> Result<String, AppError> {
some_operation().map_err(|_| AppError::DatabaseError("DB error".into()))
}
4. Middleware Ordering
The order of middleware matters. Generally, order them like:
- Logging (first)
- CORS
- Authentication
- Rate limiting
- Business logic
5. Database Connection Pooling
Always use connection pools, not individual connections:
use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() {
let pool = PgPoolOptions::new()
.max_connections(5)
.connect("postgres://user:pass@localhost/db")
.await
.unwrap();
// Share pool with handlers via state
let app = Router::new()
.with_state(pool);
}
Axum vs. Actix-web: Comparison
| Aspect | Axum | Actix-web |
|---|---|---|
| Learning Curve | Easier (more familiar patterns) | Steeper (Actor model) |
| Ecosystem Support | Official Tokio project | Community maintained |
| Type Safety | Exceptional (extractors) | Good |
| Performance | ~300k req/s* | ~370k req/s* |
| Production Readiness | Very mature (2024) | Proven (since 2017) |
| WebSocket Support | Available | Built-in |
| Middleware | Composable (tower) | Actor-based |
| Beginner Friendly | โ Yes | โ ๏ธ Moderate |
| Large Teams | โ Better composability | โ Actor patterns helpful |
*Benchmarks vary; see TechEmpower Web Framework Benchmarks for current data.
When to Choose Axum
- You’re new to Rust web development
- You want a modern, composable framework
- You prefer tower middleware ecosystem
- You need excellent compile-time safety
When to Choose Actix-web
- You need highest performance and proven production track record
- You already understand the Actor model
- You need built-in WebSocket support with actor integration
- Your team is familiar with Actix
Production Considerations
1. Error Handling & Logging
use tracing::{info, error};
use tracing_subscriber;
#[tokio::main]
async fn main() {
// Initialize tracing
tracing_subscriber::fmt::init();
let app = Router::new().route("/", get(handler));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
info!("Server starting on port 3000");
axum::serve(listener, app).await.unwrap();
}
async fn handler() -> Result<Json<serde_json::Value>, AppError> {
info!("Handling request");
Ok(Json(serde_json::json!({"status": "ok"})))
}
2. Database Integration
For PostgreSQL:
[dependencies]
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres"] }
For MongoDB:
[dependencies]
mongodb = "2.5"
3. Environment Configuration
use dotenv::dotenv;
use std::env;
#[derive(Clone)]
struct Config {
database_url: String,
port: u16,
environment: String,
}
impl Config {
fn from_env() -> Self {
dotenv().ok();
Self {
database_url: env::var("DATABASE_URL")
.expect("DATABASE_URL not set"),
port: env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.unwrap(),
environment: env::var("ENVIRONMENT")
.unwrap_or_else(|_| "development".to_string()),
}
}
}
4. CORS Configuration
use tower_http::cors::CorsLayer;
let app = Router::new()
.route("/users", get(list_users))
.layer(
CorsLayer::permissive() // In production, restrict origins
);
Testing REST APIs
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use http::StatusCode;
use tower::ServiceExt;
#[tokio::test]
async fn test_get_user() {
let state = AppState {
users: Arc::new(RwLock::new(vec![
User {
id: 1,
name: "Alice".to_string(),
email: "[email protected]".to_string(),
}
])),
};
let app = Router::new()
.route("/users/:id", get(get_user))
.with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("GET")
.uri("/users/1")
.body(Body::empty())
.unwrap()
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
Resources & Further Learning
Official Documentation
Learning Resources
- Rust Official Book - Final Project
- Axum Examples Repository
- Actix-web Examples
- TechEmpower Benchmarks
Related Crates
- sqlx: Type-safe SQL query builder and executor
- serde: Serialization/deserialization framework
- tower: Composable asynchronous services
- tracing: Structured logging and diagnostics
- tokio-util: Utilities for Tokio
- uuid: UUID generation
- chrono: Date and time handling
- jsonwebtoken: JWT token creation and validation
- bcrypt: Password hashing
Conclusion
Axum and Actix-web represent the cutting edge of Rust web development. Axum offers modern composability and excellent developer experience, making it ideal for new Rust developers building web APIs. Actix-web provides proven production reliability and extreme performance, valued by teams with performance-critical requirements.
The choice between them depends on your team’s experience and specific needs. Both are production-ready, well-maintained, and capable of handling enterprise-scale applications. Start with Axum if you’re new to Rust; consider Actix-web if you need maximum performance or are already familiar with actor-based systems.
The most important aspect is that Rust enables you to build APIs that are simultaneously safe, fast, and maintainableโa combination rarely achieved in other languages.
Comments