Async Rust with Tokio Part 6: Building a High-Performance HTTP Backend with Axum

Async Rust with Tokio Part 6: Building a High-Performance HTTP Backend with Axum

The previous five parts built the foundation. Now we put it into production. This post builds a complete HTTP backend service using Axum – Tokio’s first-party web framework – with connection pooling via SQLx, structured middleware, graceful shutdown, and the patterns that make services reliable under real load.

Project Setup

# Cargo.toml
[package]
name = "backend-service"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["macros"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "cors", "timeout", "limit"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio", "uuid", "time"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "1"

Application State and Error Types

use std::sync::Arc;
use sqlx::PgPool;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;

// Shared application state - cloned cheaply into each handler
#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
    pub config: Arc,
}

#[derive(Debug)]
pub struct Config {
    pub database_url: String,
    pub port: u16,
    pub max_connections: u32,
}

// Unified error type for handlers
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("Not found: {0}")]
    NotFound(String),

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

    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

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

// Convert errors to HTTP responses automatically
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
            AppError::Validation(msg) => (StatusCode::BAD_REQUEST, 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 error".to_string())
            }
        };

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

Database Connection Pool

use sqlx::postgres::PgPoolOptions;
use std::time::Duration;

async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Result {
    let pool = PgPoolOptions::new()
        .max_connections(max_connections)
        .min_connections(5)                        // keep min connections warm
        .acquire_timeout(Duration::from_secs(3))   // fail fast on pool exhaustion
        .idle_timeout(Duration::from_secs(600))    // close idle connections after 10min
        .max_lifetime(Duration::from_secs(1800))   // recycle connections every 30min
        .connect(database_url)
        .await?;

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

    Ok(pool)
}

Routing and Handlers

use axum::{
    Router,
    routing::{get, post, put, delete},
    extract::{Path, State, Json as ExtractJson},
    response::Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub created_at: time::OffsetDateTime,
}

#[derive(Debug, Deserialize)]
pub struct CreateUser {
    pub email: String,
    pub name: String,
}

// Handler returns Result
// Axum calls IntoResponse on both Ok and Err variants
async fn create_user(
    State(state): State,
    ExtractJson(payload): ExtractJson,
) -> Result<(StatusCode, Json), AppError> {
    // Validate
    if payload.email.is_empty() {
        return Err(AppError::Validation("email is required".into()));
    }

    let user = sqlx::query_as::<_, User>(
        "INSERT INTO users (id, email, name) VALUES ($1, $2, $3) RETURNING *"
    )
    .bind(Uuid::new_v4())
    .bind(&payload.email)
    .bind(&payload.name)
    .fetch_one(&state.db)
    .await?;

    Ok((StatusCode::CREATED, Json(user)))
}

async fn get_user(
    State(state): State,
    Path(user_id): Path,
) -> Result, AppError> {
    let user = sqlx::query_as::<_, User>(
        "SELECT * FROM users WHERE id = $1"
    )
    .bind(user_id)
    .fetch_optional(&state.db)
    .await?
    .ok_or_else(|| AppError::NotFound(format!("User {} not found", user_id)))?;

    Ok(Json(user))
}

async fn list_users(
    State(state): State,
) -> Result>, AppError> {
    let users = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY created_at DESC")
        .fetch_all(&state.db)
        .await?;
    Ok(Json(users))
}

pub fn user_routes() -> Router {
    Router::new()
        .route("/users", get(list_users).post(create_user))
        .route("/users/:id", get(get_user))
}

Middleware Stack

use tower_http::{
    trace::TraceLayer,
    cors::CorsLayer,
    timeout::TimeoutLayer,
    limit::RequestBodyLimitLayer,
};
use axum::http::{Method, HeaderValue};

fn build_middleware_stack() -> tower::ServiceBuilder) -> std::future::Ready>>>> {
    tower::ServiceBuilder::new()
        // Request tracing - logs method, path, status, latency
        .layer(TraceLayer::new_for_http())
        // Global request timeout - prevents slow clients from holding connections
        .layer(TimeoutLayer::new(Duration::from_secs(30)))
        // Request body size limit - prevents large payload attacks
        .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024)) // 10MB
        // CORS
        .layer(
            CorsLayer::new()
                .allow_origin("https://yourdomain.com".parse::().unwrap())
                .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
        )
}

pub fn build_router(state: AppState) -> Router {
    Router::new()
        .route("/health", get(health_check))
        .nest("/api/v1", user_routes())
        .with_state(state)
        .layer(TraceLayer::new_for_http())
        .layer(TimeoutLayer::new(Duration::from_secs(30)))
        .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024))
}

async fn health_check(State(state): State) -> impl IntoResponse {
    match sqlx::query("SELECT 1").fetch_one(&state.db).await {
        Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "status": "ok" }))),
        Err(_) => (StatusCode::SERVICE_UNAVAILABLE, Json(serde_json::json!({ "status": "degraded" }))),
    }
}

Graceful Shutdown

use tokio::net::TcpListener;
use tokio::signal;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Initialize tracing
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();

    let config = Arc::new(Config {
        database_url: std::env::var("DATABASE_URL")?,
        port: std::env::var("PORT").unwrap_or("8080".into()).parse()?,
        max_connections: 20,
    });

    let db = create_pool(&config.database_url, config.max_connections).await?;
    let state = AppState { db, config: Arc::clone(&config) };
    let app = build_router(state);

    let listener = TcpListener::bind(format!("0.0.0.0:{}", config.port)).await?;
    tracing::info!("Listening on port {}", config.port);

    // Serve with graceful shutdown
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;

    tracing::info!("Server shut down gracefully");
    Ok(())
}

async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c()
            .await
            .expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("failed to install SIGTERM handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => { tracing::info!("Received Ctrl+C"); },
        _ = terminate => { tracing::info!("Received SIGTERM"); },
    }
}

Architecture Overview

flowchart TD
    Client -->|HTTP Request| L[TcpListener]
    L -->|accept| A[Axum Router]
    A --> MW1[TraceLayer]
    MW1 --> MW2[TimeoutLayer]
    MW2 --> MW3[CorsLayer]
    MW3 --> H[Handler fn]
    H -->|query| P[PgPool]
    P -->|connection| DB[(PostgreSQL)]
    DB -->|result| P
    P -->|result| H
    H -->|AppError| E[IntoResponse]
    H -->|Ok response| R[JSON Response]
    E --> R
    R --> Client

What Comes Next

Part 7 tackles error handling and cancellation safety – two areas where async Rust has specific behaviors that differ from synchronous code. Cancellation-safe futures, the select! macro in depth, and what happens to your data when a future is dropped mid-operation.

References

Written by:

649 Posts

View All Posts
Follow Me :
How to whitelist website on AdBlocker?

How to whitelist website on AdBlocker?

  1. 1 Click on the AdBlock Plus icon on the top right corner of your browser
  2. 2 Click on "Enabled on this site" from the AdBlock Plus option
  3. 3 Refresh the page and start browsing the site