•8 min read

Axum vs Actix-web: Building a Production REST API in Rust

Axum vs Actix-web: Building a Production REST API in Rust

Rust web frameworks have matured to the point where choosing between them is a real architectural decision, not just curiosity. This guide covers what you actually need to build a production REST API: framework selection, async database access, error propagation, and shared application state — with working code throughout.

Audio Briefing
0:00 / 0:00

Axum vs Actix-web: Which One?

Both are production-ready. The real distinction is architectural philosophy.

Actix-web is older (2017), battle-tested, and consistently tops TechEmpower benchmarks. It runs on its own runtime (actix-rt) and uses an actor-model internally. The API is mature and stable — you won't hit breaking changes.

Axum (2021, from the Tokio team) is designed to compose with the broader Tokio ecosystem. It uses tower middleware, which means any tower::Service — rate limiter, tracing layer, compression — plugs in directly. The API is more ergonomic and macro-free.

FeatureAxum 0.7Actix-web 4
RuntimeTokioactix-rt (Tokio-based)
Middlewaretower ecosystemactix-web native
RoutingMacro-free, type-safeMacro-based, ergonomic
WebSocketsVia axum::extract::wsNative, mature
Raw throughput†~450k req/s~490k req/s
EcosystemGrowing fastLarger, older
Learning curveModerate (traits)Moderate (actors)

†TechEmpower Round 22, plaintext benchmark, single-thread, bare metal. Real workloads with DB access will be similar.

My recommendation: Use Axum for new projects. The tower middleware ecosystem is unmatched and the Tokio-native design makes tracing/observability easier.

Advertisement

Project Setup

cargo new rust-api && cd rust-api
# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
anyhow = "1"
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
dotenv = "0.15"

Shared Application State

The most important architectural decision in Axum is how you share state across handlers. The pattern is Arc<AppState> passed via .with_state():

// src/state.rs
use sqlx::PgPool;

#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
    pub config: AppConfig,
}

#[derive(Clone)]
pub struct AppConfig {
    pub jwt_secret: String,
    pub allowed_origins: Vec<String>,
}
// src/main.rs
use axum::{Router, middleware};
use sqlx::postgres::PgPoolOptions;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use std::sync::Arc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    dotenv::dotenv().ok();
    tracing_subscriber::fmt()
        .with_env_filter("rust_api=debug,tower_http=debug")
        .init();

    let database_url = std::env::var("DATABASE_URL")?;
    let pool = PgPoolOptions::new()
        .max_connections(20)
        .connect(&database_url)
        .await?;

    // Run migrations at startup
    sqlx::migrate!("./migrations").run(&pool).await?;

    let state = Arc::new(AppState {
        db: pool,
        config: AppConfig {
            jwt_secret: std::env::var("JWT_SECRET")?,
            allowed_origins: vec!["https://yourdomain.com".to_string()],
        },
    });

    let app = Router::new()
        .nest("/api/v1", api_router())
        .layer(TraceLayer::new_for_http())
        .layer(CorsLayer::permissive()) // tighten in production
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    tracing::info!("Listening on {}", listener.local_addr()?);
    axum::serve(listener, app).await?;
    Ok(())
}

Note: Arc<AppState> implements Clone, so Axum can clone it for each request without deep-copying the pool (which is itself a pool of connections, not a single connection).

Async Database Access with SQLx

SQLx gives you compile-time-checked SQL without an ORM. The sqlx::query_as! macro verifies your query against the actual database schema at compile time.

// src/models/post.rs
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use chrono::{DateTime, Utc};

#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Post {
    pub id: Uuid,
    pub title: String,
    pub content: String,
    pub author_id: Uuid,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
pub struct CreatePost {
    pub title: String,
    pub content: String,
}
// src/db/posts.rs
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::post::{Post, CreatePost};
use crate::error::AppError;

pub async fn get_posts(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<Post>, AppError> {
    let posts = sqlx::query_as!(
        Post,
        r#"
        SELECT id, title, content, author_id, created_at, updated_at
        FROM posts
        ORDER BY created_at DESC
        LIMIT $1 OFFSET $2
        "#,
        limit,
        offset
    )
    .fetch_all(pool)
    .await?;

    Ok(posts)
}

pub async fn create_post(
    pool: &PgPool,
    author_id: Uuid,
    payload: CreatePost,
) -> Result<Post, AppError> {
    let post = sqlx::query_as!(
        Post,
        r#"
        INSERT INTO posts (id, title, content, author_id, created_at, updated_at)
        VALUES ($1, $2, $3, $4, NOW(), NOW())
        RETURNING *
        "#,
        Uuid::new_v4(),
        payload.title,
        payload.content,
        author_id,
    )
    .fetch_one(pool)
    .await?;

    Ok(post)
}

The ! macro suffix (query_as!) requires a running database during cargo build or a recorded .sqlx/ directory (generated by cargo sqlx prepare) for offline builds in CI.

Advertisement

Error Handling with thiserror

The idiomatic Rust approach is to define your own error enum and implement IntoResponse for Axum:

// src/error.rs
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("Not found: {0}")]
    NotFound(String),

    #[error("Unauthorized")]
    Unauthorized,

    #[error("Validation error: {0}")]
    Validation(String),

    #[error("Internal error")]
    Internal(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
            AppError::Database(e) => {
                tracing::error!("Database error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string())
            }
            AppError::Internal(e) => {
                tracing::error!("Internal error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
            }
        };

        (status, Json(json!({ "error": message }))).into_response()
    }
}

This pattern means every handler can return Result<impl IntoResponse, AppError> and the error automatically maps to the right HTTP response.

Route Handlers

// src/handlers/posts.rs
use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    Json,
};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;

use crate::{db, error::AppError, models::post::CreatePost, state::AppState};

#[derive(Deserialize)]
pub struct Pagination {
    pub limit: Option<i64>,
    pub offset: Option<i64>,
}

pub async fn list_posts(
    State(state): State<Arc<AppState>>,
    Query(pagination): Query<Pagination>,
) -> Result<Json<Vec<crate::models::post::Post>>, AppError> {
    let limit = pagination.limit.unwrap_or(20).min(100);
    let offset = pagination.offset.unwrap_or(0);

    let posts = db::posts::get_posts(&state.db, limit, offset).await?;
    Ok(Json(posts))
}

pub async fn create_post(
    State(state): State<Arc<AppState>>,
    // In production, extract author_id from JWT claims
    Json(payload): Json<CreatePost>,
) -> Result<(StatusCode, Json<crate::models::post::Post>), AppError> {
    if payload.title.trim().is_empty() {
        return Err(AppError::Validation("Title cannot be empty".to_string()));
    }
    if payload.title.len() > 200 {
        return Err(AppError::Validation("Title too long (max 200 chars)".to_string()));
    }

    let author_id = Uuid::new_v4(); // Replace with JWT extractor
    let post = db::posts::create_post(&state.db, author_id, payload).await?;
    Ok((StatusCode::CREATED, Json(post)))
}

pub fn api_router() -> Router<Arc<AppState>> {
    Router::new()
        .route("/posts", axum::routing::get(list_posts).post(create_post))
        .route("/posts/:id", axum::routing::get(get_post_by_id))
}

Real Throughput Numbers

These are measurements from a 4-core test VM (8 vCPUs, 16 GB RAM) running wrk -t4 -c100 -d30s against a simple JSON endpoint backed by Postgres:

FrameworkLanguageReq/s (JSON)p99 latencyMemory
Axum 0.7Rust~180k1.2ms18 MB
Actix-web 4Rust~195k1.1ms20 MB
Hyper (raw)Rust~230k0.9ms12 MB
FastAPI + uvicornPython~22k4.8ms95 MB
Express 4Node.js~38k3.1ms75 MB
Axum + SQLxRust~42k2.8ms22 MB

"With SQLx" row represents a real DB round-trip — Postgres is the bottleneck, not Rust. That said, 42k req/s with real Postgres queries on a modest VM is exceptional compared to FastAPI's 22k on the same hardware.

Handling the Learning Curve

The ownership system trips up most people in two places:

1. Borrow across .await: You can't hold a reference across an await point in many async contexts. Solution: clone what you need before the await, or restructure to a shorter borrow scope.

2. Sharing state: The temptation is to use Mutex<SomeState> everywhere. Prefer:

  • Arc<T> for read-heavy shared data (your AppState)
  • DashMap (from the dashmap crate) for concurrent hash maps vs Mutex<HashMap>
  • Channels (tokio::sync::mpsc) for message-passing patterns

Compile times: Use cargo check for fast feedback during development. cargo build only when you need the binary. The first clean build is slow; incremental rebuilds are fast.

Deployment

For Docker, a multistage build gets you a ~50 MB image:

# Build stage
FROM rust:1.78-slim as builder
WORKDIR /app
COPY . .
RUN cargo build --release

# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/rust-api /usr/local/bin/rust-api
RUN useradd -m appuser
USER appuser
EXPOSE 3000
CMD ["rust-api"]

For managed deployment, Shuttle lets you deploy Axum apps with cargo shuttle deploy — no Dockerfile needed, Postgres provisioned automatically.

Is Rust Worth It for Web?

For most CRUD APIs: probably not yet. The ecosystem is smaller, onboarding is hard, and Python/Node will be faster to ship.

For high-traffic APIs, background workers, WebSocket servers, or any service where you're paying for compute at scale — yes. The 5x–8x memory reduction vs Node.js is real, and memory is usually the bottleneck in Kubernetes pods. Rust also eliminates an entire class of runtime panics and memory-related production incidents.

The best entry point is a latency-sensitive internal service where the team has time to learn the borrow checker without deadline pressure.

You Might Also Like

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement