Skip to main content
โšก Calmops

Building REST APIs with Axum and Actix-web

A Comprehensive Guide to Modern Rust Web Frameworks

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:

  1. 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.
  2. Fearless Concurrency: Rust’s type system makes it impossible to accidentally share mutable state across threads without proper synchronization.
  3. Performance: Zero-cost abstractions and no garbage collection overhead mean Rust APIs can handle more concurrent connections with fewer resources.
  4. 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:

  1. Composable: Everything is modular; handlers, extractors, and middleware compose cleanly
  2. Ergonomic: Leverages Rust’s type system to provide excellent compile-time safety
  3. 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:

  1. Actor-based: Uses Actix’s actor framework for internal concurrency
  2. Battle-tested: Proven in production for years
  3. Feature-rich: Includes built-in WebSocket support, file uploads, streaming
  4. 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:

  1. Logging (first)
  2. CORS
  3. Authentication
  4. Rate limiting
  5. 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

  • 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