MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Rust for Web: Axum, Actix, and WebAssembly

Build web services in Rust: Axum framework, async runtime, and WASM targets.

RustWebAxumWASM

By MinhVo

Introduction

Rust in the web backend space is no longer experimental—it's production-ready and powering services at companies like Cloudflare, Discord, and Figma. While Node.js and Go dominate the web services landscape, Rust offers something neither can: predictable performance with zero garbage collection pauses, memory safety guaranteed at compile time, and the ability to compile to WebAssembly for running the same code on both server and client. For latency-sensitive services, high-throughput APIs, and security-critical backends, Rust is increasingly the right tool.

This guide focuses on the practical aspects of building web services in Rust. We'll cover the async runtime ecosystem with Tokio, build APIs with the three dominant frameworks (Axum, Actix-web, and Rocket), implement real-world patterns like authentication and database access, and show how to compile your Rust web server to WebAssembly for edge computing. By the end, you'll have a working understanding of when and how to use Rust for web development.

Rust web development

Understanding Async Rust: The Tokio Runtime

Rust's async model is fundamentally different from Node.js's event loop or Go's goroutines. Rust async functions return Future objects that do nothing until explicitly polled by an executor. The tokio runtime is the dominant executor, providing a multi-threaded scheduler, I/O driver, and timer facility.

Why Tokio

Tokio is the de facto standard for async Rust with good reason: it's battle-tested at scale (used by Discord, AWS, Cloudflare), has the richest ecosystem of compatible libraries, and offers both multi-threaded and single-threaded runtimes. The multi-threaded runtime distributes tasks across CPU cores using a work-stealing scheduler—similar to Go's goroutine scheduler but with Rust's zero-cost abstractions.

use tokio;
 
#[tokio::main]
async fn main() {
    // Spawn concurrent tasks (like Promise.all)
    let handle1 = tokio::spawn(async { fetch_user(1).await });
    let handle2 = tokio::spawn(async { fetch_orders(1).await });
 
    let (user, orders) = tokio::join!(handle1, handle2);
    println!("User: {:?}, Orders: {:?}", user.unwrap(), orders.unwrap());
}

Async Patterns

use tokio::time::{sleep, Duration};
 
// Timeout pattern
async fn fetch_with_timeout(url: &str) -> Result<String, reqwest::Error> {
    tokio::time::timeout(Duration::from_secs(5), reqwest::get(url).await?.text().await)
        .await
        .map_err(|_| reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout")))?
}
 
// Semaphore for rate limiting
use tokio::sync::Semaphore;
use std::sync::Arc;
 
async fn rate_limited_requests(urls: Vec<&str>) {
    let semaphore = Arc::new(Semaphore::new(10)); // Max 10 concurrent
    let mut handles = vec![];
 
    for url in urls {
        let permit = semaphore.clone().acquire_owned().await.unwrap();
        handles.push(tokio::spawn(async move {
            let result = reqwest::get(url).await;
            drop(permit); // Release the permit
            result
        }));
    }
 
    for handle in handles {
        let _ = handle.await;
    }
}

Axum: The Ergonomic Choice

Axum is the newest of the three major Rust web frameworks, built by the Tokio team. It leverages Rust's type system for request handling with an extractor pattern that makes handler signatures self-documenting.

Building an API with Axum

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::IntoResponse,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
 
#[derive(Clone)]
struct AppState {
    db: PgPool,
}
 
#[derive(Serialize)]
struct User {
    id: Uuid,
    name: String,
    email: String,
}
 
#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}
 
#[derive(Deserialize)]
struct ListParams {
    page: Option<i64>,
    per_page: Option<i64>,
}
 
async fn list_users(
    State(state): State<AppState>,
    Query(params): Query<ListParams>,
) -> Result<Json<Vec<User>>, StatusCode> {
    let page = params.page.unwrap_or(1);
    let per_page = params.per_page.unwrap_or(20).min(100);
 
    let users = sqlx::query_as!(
        User,
        "SELECT id, name, email FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2",
        per_page,
        (page - 1) * per_page
    )
    .fetch_all(&state.db)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
 
    Ok(Json(users))
}
 
async fn create_user(
    State(state): State<AppState>,
    Json(input): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), StatusCode> {
    let user = sqlx::query_as!(
        User,
        "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email",
        input.name,
        input.email
    )
    .fetch_one(&state.db)
    .await
    .map_err(|e| match e {
        sqlx::Error::Database(db_err) if db_err.constraint().is_some() => StatusCode::CONFLICT,
        _ => StatusCode::INTERNAL_SERVER_ERROR,
    })?;
 
    Ok((StatusCode::CREATED, Json(user)))
}
 
async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<User>, StatusCode> {
    let user = sqlx::query_as!(
        User,
        "SELECT id, name, email FROM users WHERE id = $1",
        id
    )
    .fetch_optional(&state.db)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
 
    user.ok_or(StatusCode::NOT_FOUND).map(Json)
}
 
#[tokio::main]
async fn main() {
    let db = PgPool::connect(&std::env::var("DATABASE_URL").unwrap())
        .await
        .unwrap();
 
    let state = AppState { db };
 
    let app = Router::new()
        .route("/api/users", get(list_users).post(create_user))
        .route("/api/users/{id}", get(get_user))
        .with_state(state);
 
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Axum's extractor system is its key differentiator. Function parameters are automatically extracted from the request: State for shared application state, Path for URL path parameters, Query for query strings, Json for JSON bodies, and HeaderMap for headers. Adding a new extraction is as simple as adding a function parameter—the order doesn't matter.

Rust web architecture

Architecture and Middleware

Authentication Middleware in Axum

use axum::{
    extract::Request,
    http::{header, StatusCode},
    middleware::{self, Next},
    response::Response,
    Router,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
 
#[derive(Clone)]
struct AuthConfig {
    jwt_secret: String,
}
 
#[derive(Debug, serde::Deserialize)]
struct Claims {
    sub: String,
    roles: Vec<String>,
    exp: usize,
}
 
async fn auth_middleware(
    State(config): State<AuthConfig>,
    mut request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let auth_header = request
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .ok_or(StatusCode::UNAUTHORIZED)?;
 
    let token = auth_header
        .strip_prefix("Bearer ")
        .ok_or(StatusCode::UNAUTHORIZED)?;
 
    let claims = decode::<Claims>(
        token,
        &DecodingKey::from_secret(config.jwt_secret.as_ref()),
        &Validation::default(),
    )
    .map_err(|_| StatusCode::UNAUTHORIZED)?
    .claims;
 
    request.extensions_mut().insert(claims);
    Ok(next.run(request).await)
}
 
// Usage
let protected_routes = Router::new()
    .route("/api/users", get(list_users))
    .route_layer(middleware::from_fn_with_state(auth_config, auth_middleware));

Error Handling Strategy

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
 
enum AppError {
    NotFound(String),
    Validation(Vec<String>),
    Unauthorized,
    Internal(anyhow::Error),
}
 
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, body) = match self {
            AppError::NotFound(msg) => (
                StatusCode::NOT_FOUND,
                json!({ "error": { "code": "NOT_FOUND", "message": msg } }),
            ),
            AppError::Validation(errors) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({ "error": { "code": "VALIDATION_ERROR", "details": errors } }),
            ),
            AppError::Unauthorized => (
                StatusCode::UNAUTHORIZED,
                json!({ "error": { "code": "UNAUTHORIZED", "message": "Invalid credentials" } }),
            ),
            AppError::Internal(err) => {
                tracing::error!("Internal error: {:?}", err);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": { "code": "INTERNAL_ERROR", "message": "An unexpected error occurred" } }),
                )
            }
        };
 
        (status, Json(body)).into_response()
    }
}
 
// Handlers return Result<T, AppError>
async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<User>, AppError> {
    let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
        .fetch_optional(&state.db)
        .await
        .map_err(|e| AppError::Internal(e.into()))?;
 
    user.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))
        .map(Json)
}

Step-by-Step Implementation

Full CRUD with Database and Tests

use sqlx::PgPool;
use axum_test::TestServer;
 
async fn setup_test_db() -> PgPool {
    let pool = PgPool::connect("postgres://localhost/test_db").await.unwrap();
    sqlx::migrate!().run(&pool).await.unwrap();
    pool
}
 
#[tokio::test]
async fn test_create_and_get_user() {
    let db = setup_test_db().await;
    let app = create_app(db.clone());
    let server = TestServer::new(app).unwrap();
 
    // Create user
    let response = server
        .post("/api/users")
        .json(&serde_json::json!({
            "name": "Alice",
            "email": "alice@example.com"
        }))
        .await;
 
    assert_eq!(response.status_code(), 201);
    let user: User = response.json();
    assert_eq!(user.name, "Alice");
 
    // Get user
    let response = server.get(&format!("/api/users/{}", user.id)).await;
    assert_eq!(response.status_code(), 200);
    let fetched: User = response.json();
    assert_eq!(fetched.email, "alice@example.com");
 
    // Not found
    let response = server.get("/api/users/00000000-0000-0000-0000-000000000000").await;
    assert_eq!(response.status_code(), 404);
}

Rust WASM compilation

Real-World Use Cases and Case Studies

Use Case 1: Cloudflare Workers with Rust/WASM

A content delivery company deployed their image transformation pipeline as Cloudflare Workers written in Rust. The Rust code compiled to a 2MB WASM module that ran at 300+ edge locations worldwide. Each worker handled image resize, format conversion, and watermarking with sub-10ms latency—20x faster than their previous Node.js Lambda@Edge implementation. The WASM binary loaded in under 1ms (cold start) versus 200ms for Node.js.

Use Case 2: High-Frequency Trading API

A financial technology firm built their order matching engine API in Rust with Actix-web. The service processed 100,000 orders per second with a p99 latency of 200 microseconds. Using Rust eliminated GC pauses that caused occasional latency spikes in their previous Java implementation. The team estimated that each GC pause (averaging 50ms) cost their clients $10,000 in missed trading opportunities.

Use Case 3: Discord's Infrastructure

Discord rewrote their Read States service from Go to Rust, eliminating the latency spikes caused by Go's garbage collector. The Rust version used 10x less memory, eliminated GC pauses entirely, and reduced p99 latency from 6ms to 300 microseconds. The service handles millions of concurrent WebSocket connections and processes billions of events per day.

Use Case 4: Embedded Web Server

A hardware startup embedded an Axum web server in their IoT device firmware running on a Raspberry Pi Zero. The Rust server used 5MB of RAM (versus 50MB for their Node.js prototype), started in 10ms, and handled sensor data uploads and configuration changes over a local network. The small binary size (3MB) fit within the device's 512MB storage alongside the Linux kernel and other firmware components.

Best Practices for Production

  1. Use tracing instead of println!: The tracing crate provides structured logging with spans, events, and context propagation. It integrates with axum via tower-http's TraceLayer middleware. Use tracing-subscriber with EnvFilter for configurable log levels per module.

  2. Implement graceful shutdown: Handle SIGTERM signals to finish in-flight requests before exiting. Axum's serve method accepts a shutdown signal:

let signal = tokio::signal::ctrl_c();
axum::serve(listener, app)
    .with_graceful_shutdown(signal)
    .await
    .unwrap();
  1. Use connection pooling for databases: SQLx's PgPool manages a pool of database connections automatically. Configure pool size based on your database's max_connections divided by your application instances, leaving headroom for migrations and admin connections.

  2. Extract business logic from handlers: Keep handlers thin—extract request data, call a service layer, and format the response. Business logic in handlers is hard to test and reuse. Use the State extractor to inject service dependencies.

  3. Use tower-http middleware for cross-cutting concerns: CORS, compression, rate limiting, request tracing, and timeout middleware are available as ready-made Tower layers. Compose them in the Router to avoid reimplementing common patterns.

  4. Implement health check endpoints: Expose /health (liveness) and /health/ready (readiness) endpoints. The readiness check should verify database connectivity and other dependencies. Kubernetes and load balancers use these for routing decisions.

  5. Use SQLx compile-time query checking: SQLx's query! macro checks SQL queries against your database schema at compile time. This catches column name typos, type mismatches, and missing tables before code reaches production. Run cargo sqlx prepare to cache query metadata for CI/CD.

  6. Profile with flamegraph and cargo-instruments: Use cargo flamegraph to identify CPU bottlenecks and macOS Instruments for memory profiling. Rust code is generally fast, but algorithmic issues and unnecessary allocations still occur. Profile before optimizing—the compiler's optimizer handles most micro-optimizations automatically.

Common Pitfalls and Solutions

PitfallImpactSolution
Blocking the async runtimeAll tasks stall, latency spikesUse tokio::spawn_blocking() for CPU-intensive work; never call std::thread::sleep in async code
Too many clone() callsUnnecessary memory allocationsUse references (&) and lifetimes; use Arc<T> for shared ownership across async tasks
Error handling with .unwrap()Panics crash the entire serverUse ? operator with custom error types; implement IntoResponse for your error enum
Missing backpressureMemory exhaustion under loadUse bounded channels, semaphores, and connection pool limits to prevent unbounded buffering
Forgetting to index database queriesSlow queries under loadAdd indexes for all columns used in WHERE, ORDER BY, and JOIN clauses; use EXPLAIN ANALYZE to verify
Mixing sync and async codeThread pool exhaustionUse async versions of all I/O libraries (reqwest, sqlx, redis); convert sync code with spawn_blocking

Performance Optimization

Connection Pool Tuning

let pool = PgPoolOptions::new()
    .max_connections(20)
    .min_connections(5)
    .acquire_timeout(Duration::from_secs(3))
    .idle_timeout(Duration::from_secs(600))
    .max_lifetime(Duration::from_secs(1800))
    .connect(&database_url)
    .await?;

JSON Serialization

Use simd-json for faster JSON parsing when handling large payloads:

use simd_json::serde::from_slice;
 
async fn handle_bulk_import(mut body: Bytes) -> Result<Json<Value>, StatusCode> {
    let mut data = body.to_vec();
    let value: Value = from_slice(&mut data)
        .map_err(|_| StatusCode::BAD_REQUEST)?;
    // Process...
}

Response Compression

use tower_http::compression::CompressionLayer;
 
let app = Router::new()
    .route("/api/users", get(list_users))
    .layer(CompressionLayer::new());

Comparison with Alternatives

FeatureAxumActix-webRocketExpress.jsGo net/http
PerformanceExcellentExcellent (fastest)Very goodGoodExcellent
ErgonomicsExcellent (type-safe extractors)GoodExcellent (macro-driven)ExcellentGood
Async runtimeTokio (built-in)Tokio or ActixTokioN/A (event loop)Goroutines
MiddlewareTower layersActix middlewareFairingsExpress middlewarenet/http handlers
Learning curveModerateModerateLow-moderateLowLow
Best forProduction APIs, microservicesMaximum performanceRapid developmentWeb apps, prototypingBackend services

Axum is the recommended choice for new Rust web projects—it has the best ergonomics, Tokio team backing, and growing ecosystem. Actix-web is slightly faster in benchmarks but has a steeper learning curve. Rocket offers the most approachable API but has a smaller ecosystem and slower development pace.

Advanced Patterns and Techniques

Server-Sent Events

use axum::response::sse::{Event, KeepAlive, Sse};
use futures::stream::Stream;
 
async fn event_stream(
    State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let mut rx = state.event_tx.subscribe();
 
    Sse::new(async_stream::stream! {
        while let Ok(event) = rx.recv().await {
            yield Ok(Event::default().data(serde_json::to_string(&event).unwrap()));
        }
    })
    .keep_alive(KeepAlive::default())
}

OpenAPI Documentation with utoipa

use utoipa::{OpenApi, ToSchema};
use utoipa_swagger_ui::SwaggerUi;
 
#[derive(ToSchema, Serialize)]
struct User {
    id: Uuid,
    name: String,
    email: String,
}
 
#[utoipa::path(
    get,
    path = "/api/users/{id}",
    responses(
        (status = 200, description = "User found", body = User),
        (status = 404, description = "User not found")
    ),
    params(("id" = Uuid, Path, description = "User ID"))
)]
async fn get_user(/* ... */) { /* ... */ }
 
#[derive(OpenApi)]
#[openapi(paths(get_user), components(schemas(User)))]
struct ApiDoc;
 
let app = Router::new()
    .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));

Testing Strategies

Integration testing with axum-test provides full request/response cycle testing without starting a real server:

use axum_test::TestServer;
 
#[tokio::test]
async fn test_full_crud_cycle() {
    let db = setup_test_db().await;
    let app = create_app(db);
    let server = TestServer::new(app).unwrap();
 
    // Create
    let user = server.post("/api/users")
        .json(&json!({"name": "Test", "email": "test@example.com"}))
        .await;
    assert_eq!(user.status_code(), 201);
    let created: User = user.json();
 
    // Read
    let fetched = server.get(&format!("/api/users/{}", created.id)).await;
    assert_eq!(fetched.status_code(), 200);
 
    // Update
    let updated = server.put(&format!("/api/users/{}", created.id))
        .json(&json!({"name": "Updated"}))
        .await;
    assert_eq!(updated.status_code(), 200);
 
    // Delete
    let deleted = server.delete(&format!("/api/users/{}", created.id)).await;
    assert_eq!(deleted.status_code(), 204);
 
    // Verify deletion
    let gone = server.get(&format!("/api/users/{}", created.id)).await;
    assert_eq!(gone.status_code(), 404);
}

Future Outlook

Rust's web ecosystem is maturing rapidly. The axum framework has become the standard, with the Tokio team's full backing and a growing middleware ecosystem through Tower. WebAssembly support is advancing with the component model, enabling Rust services to run at the edge with near-native performance.

The rise of edge computing platforms (Cloudflare Workers, Deno Deploy, Fastly Compute) is creating demand for Rust/WASM services that can run in resource-constrained environments with cold starts measured in microseconds. Rust's small binary sizes and predictable memory usage make it ideal for this deployment model.

Rust is also influencing other web frameworks. The extractors pattern pioneered by Axum is being adopted in other language ecosystems, and Rust's type-driven API design is setting new standards for how web frameworks should leverage their host language's type system.

Conclusion

Rust for web development offers a compelling combination of performance, safety, and developer ergonomics that no other language matches. Axum provides the best entry point for new projects with its type-safe extractors and Tokio integration. Actix-web is the choice for maximum raw performance. And WASM targets enable deploying Rust services to the edge with sub-millisecond cold starts.

Key takeaways:

  1. Start with Axum—it has the best ergonomics, strongest community momentum, and Tokio team support
  2. Use Tokio as your async runtime; it's the industry standard with the largest ecosystem
  3. Implement structured logging with tracing from day one—debugging async code without logs is painful
  4. Use SQLx for compile-time query verification—catch SQL bugs before they reach production
  5. Keep handlers thin and extract business logic into testable service layers
  6. Use Tower middleware for cross-cutting concerns like CORS, compression, and rate limiting
  7. Profile with flamegraph before optimizing—the compiler handles most optimizations automatically

Rust web development has a steeper learning curve than Node.js or Go, but the investment pays dividends in performance, reliability, and maintainability. Start with a small API, leverage the extractor pattern for clean handler code, and gradually add middleware as your service grows.

For further learning, explore the Axum documentation, the Tokio tutorial, the Rust and WebAssembly book, and the Zero to Production in Rust book for production-focused patterns.