Skip to main content
  1. Languages/
  2. Rust Guides/

Axum 101: Building High-Performance REST APIs in Rust

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

In the evolving landscape of 2025, Rust has firmly established itself not just as a systems language, but as a premier choice for backend web development. While the ecosystem used to be fragmented, Axum has emerged as the clear standard for many developers.

Why? Because it is ergonomic, modular, and built directly on top of Tokio (the industry-standard async runtime) and Tower (a robust service abstraction). If you are looking to build a microservice or a monolith that can handle thousands of requests per second with the type safety Rust is famous for, you are in the right place.

In this guide, we aren’t just going to print “Hello World.” We are going to build a functional Task Management API with CRUD operations, shared state concurrency, and proper error handling.

Prerequisites
#

Before we dive into the code, ensure your environment is ready. We are targeting intermediate Rust developers, so we assume you are comfortable with the borrow checker and basic syntax.

  • Rust Toolchain: Ensure you are running the latest stable version (Rust 1.83+ recommended).
    rustc --version
  • Package Manager: We will use cargo.
  • IDE: VS Code with rust-analyzer or JetBrains RustRover is highly recommended for autocomplete functionality, especially with Axum’s sophisticated type system.
  • HTTP Client: curl or a tool like Postman/Insomnia to test our endpoints.

Why Axum? A Quick Comparison
#

If you come from a Python (FastAPI) or Node.js (Express) background, Axum will feel surprisingly familiar, yet significantly faster. But how does it stack up against other Rust frameworks?

Feature Axum Actix-web Rocket
Async Runtime Tokio (Native) Tokio (Custom Wrapper) Tokio (Abstracted)
Ergonomics High (Macro-free routing) Medium (Actor model legacy) High (Heavy macro usage)
Middleware Uses tower ecosystem Custom middleware system Custom Fairings
Compile Time Fast Moderate Slow (due to macros)
Community Status De Facto Standard Mature / Stable Stable / Less Active

Axum’s greatest strength is its implementation of the tower::Service trait, meaning standard middleware for timeouts, tracing, and compression work out of the box.

The Architecture of Our API
#

Before writing code, let’s visualize how a request flows through an Axum application. Understanding the concept of Extractors is key to mastering this framework.

graph TD Client([Client Request]) --> Router[Axum Router] subgraph Middleware Layer Router --> Log[Tracing/Logger] Log --> Auth[Auth Middleware] end subgraph Handler Layer Auth -- "Valid Request" --> Handler{Route Handler} Handler -- "Extract State" --> AppState[(Shared AppState)] Handler -- "Extract JSON" --> Payload[Request Body] end Handler -- "Result<Json, AppError>" --> ResponseBuilder ResponseBuilder --> ClientResponse([HTTP Response]) style Client fill:#f9f,stroke:#333,stroke-width:2px style AppState fill:#ccf,stroke:#333,stroke-width:2px style Router fill:#cfc,stroke:#333,stroke-width:2px

Step 1: Project Setup and Dependencies
#

Let’s initialize a new binary project.

cargo new axum_task_api
cd axum_task_api

We need to add a few heavyweight crates to our Cargo.toml.

  • axum: The web framework.
  • tokio: The async runtime (we need the full feature).
  • serde: For serializing/deserializing JSON.
  • tracing / tracing-subscriber: For logging (essential in production).

Open Cargo.toml and add the following:

[package]
name = "axum_task_api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }

Step 2: Defining the Application State
#

In a real-world scenario, you would connect to a database like PostgreSQL. For this tutorial, we will use an in-memory HashMap to store our tasks. However, because Axum handles requests asynchronously across multiple threads, we cannot use a mutable global variable.

We must use thread-safe constructs: Arc (Atomic Reference Counting) and RwLock (Read/Write Lock).

Create a file named src/model.rs:

use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
    pub id: Uuid,
    pub title: String,
    pub completed: bool,
}

// Data Transfer Object (DTO) for creating a task
#[derive(Debug, Deserialize)]
pub struct CreateTaskRequest {
    pub title: String,
}

// DTO for updating a task
#[derive(Debug, Deserialize)]
pub struct UpdateTaskRequest {
    pub title: Option<String>,
    pub completed: Option<bool>,
}

Now, let’s define our state in src/main.rs. We’ll create a type alias to make the signature cleaner.

Step 3: Implementing the Handlers
#

This is where the magic happens. In Axum, handlers are just async functions that take arguments. These arguments are called Extractors. Axum looks at the function signature and automatically extracts data from the HTTP request (like the Body, Path parameters, or State).

Let’s modify src/main.rs to include our imports, state, and the first handler.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{get, patch},
    Json, Router,
};
use serde_json::{json, Value};
use std::{
    collections::HashMap,
    sync::{Arc, RwLock},
};
use uuid::Uuid;

mod model;
use model::{CreateTaskRequest, Task, UpdateTaskRequest};

// 1. Define the Application State
type Db = Arc<RwLock<HashMap<Uuid, Task>>>;

#[tokio::main]
async fn main() {
    // Initialize tracing (logging)
    tracing_subscriber::fmt::init();

    // Initialize state
    let db = Db::default();

    // 2. Build our application with routes
    let app = Router::new()
        .route("/tasks", get(get_tasks).post(create_task))
        .route("/tasks/:id", get(get_task).patch(update_task).delete(delete_task))
        .with_state(db);

    // 3. Run the server
    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();
}

The Create Handler (POST)
#

Notice how we use State<Db> to access our database and Json<CreateTaskRequest> to parse the body automatically.

async fn create_task(
    State(db): State<Db>,
    Json(payload): Json<CreateTaskRequest>,
) -> (StatusCode, Json<Task>) {
    let mut tasks = db.write().unwrap(); // Acquire write lock
    
    let task = Task {
        id: Uuid::new_v4(),
        title: payload.title,
        completed: false,
    };

    tasks.insert(task.id, task.clone());

    (StatusCode::CREATED, Json(task))
}

The Read Handlers (GET)
#

Here we read all tasks, or a specific task by ID using the Path extractor.

async fn get_tasks(State(db): State<Db>) -> Json<Vec<Task>> {
    let tasks = db.read().unwrap(); // Acquire read lock
    let tasks_vec: Vec<Task> = tasks.values().cloned().collect();
    Json(tasks_vec)
}

async fn get_task(
    Path(id): Path<Uuid>,
    State(db): State<Db>,
) -> Result<Json<Task>, StatusCode> {
    let tasks = db.read().unwrap();
    
    if let Some(task) = tasks.get(&id) {
        Ok(Json(task.clone()))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

The Update Handler (PATCH)
#

async fn update_task(
    Path(id): Path<Uuid>,
    State(db): State<Db>,
    Json(payload): Json<UpdateTaskRequest>,
) -> Result<Json<Task>, StatusCode> {
    let mut tasks = db.write().unwrap();

    if let Some(task) = tasks.get_mut(&id) {
        if let Some(title) = payload.title {
            task.title: title;
        }
        if let Some(completed) = payload.completed {
            task.completed = completed;
        }
        Ok(Json(task.clone()))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

The Delete Handler (DELETE)
#

async fn delete_task(
    Path(id): Path<Uuid>,
    State(db): State<Db>,
) -> StatusCode {
    let mut tasks = db.write().unwrap();

    if tasks.remove(&id).is_some() {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

Step 4: Testing Your API
#

With the code written, it’s time to fire it up.

  1. Run the application:
    cargo run
  2. Open a new terminal to act as the client.

Create a Task:

curl -X POST http://127.0.0.1:3000/tasks \
   -H "Content-Type: application/json" \
   -d '{"title": "Learn Axum"}'

Response: {"id":"...", "title":"Learn Axum", "completed":false}

List Tasks:

curl http://127.0.0.1:3000/tasks

Advanced: Best Practices for Production
#

While the code above works perfectly for a demo, here are three critical adjustments needed for a production-grade Axum application.

1. Proper Error Handling
#

Returning StatusCode directly is quick, but returning structured JSON errors is better for frontend clients. Implement axum::response::IntoResponse for a custom Error enum. This allows you to centralize error logic (mapping DB errors to 500s, input errors to 400s).

2. Layering and Middleware
#

Never expose an API without logging and timeouts. Use tower_http.

// Add to Cargo.toml: tower-http = { version = "0.5", features = ["trace"] }

use tower_http::trace::TraceLayer;

// In main():
let app = Router::new()
    // ... routes
    .layer(TraceLayer::new_for_http());

3. State Management
#

In our example, we used std::sync::RwLock. If you are holding locks across .await points (e.g., querying a real database), you must not use the standard library lock, as it will freeze the Tokio executor. Instead, rely on the database pool’s internal management (like sqlx::Pool) or use tokio::sync::RwLock.

Common Pitfalls
#

  • Blocking the Thread: Never perform heavy computation (cryptography, image processing) directly in an async handler. It blocks the runtime. Use tokio::task::spawn_blocking for CPU-intensive tasks.
  • Extractor Order: Axum extractors consume the request body. If you try to extract Json<Body> twice, or extract it before logging middleware has processed it, you may run into stream consumption issues.
  • Cloning State: The State extractor clones the Arc pointer, which is cheap. Do not try to pass deep references; just let the Arc do its job.

Conclusion
#

You have successfully built a high-performance, asynchronous REST API using Rust and Axum. We covered routing, JSON extraction, shared state concurrency, and HTTP methods.

Axum represents the maturity of the Rust web ecosystem. It gives you the raw performance of non-blocking I/O with the developer experience of a high-level framework.

What’s next?

  1. Replace the HashMap with a persistent database using SQLx.
  2. Add authentication using the axum-extra crate.
  3. Deploy your binary to a Docker container.

Rust is challenging, but with tools like Axum, the barrier to entry for web development is lower than ever. Happy coding