Overview of Web Frameworks in Rust

Axum vs Actix vs Rocket

Web
Choose Axum if you prioritize ergonomics, type safety, and seamless integration with the Tokio ecosystem for new projects.

The three paths forward

You just finished building a REST API in Python or JavaScript. It works, but the event loop chokes under load, and runtime type errors keep slipping through to production. You decide to rewrite it in Rust. You search for a web framework and immediately hit a fork in the road: Axum, Actix-web, or Rocket. They all serve HTTP. They all handle JSON. They all promise safety. But they solve the problem from completely different angles. Picking the wrong one means spending your first month fighting the framework instead of shipping your API.

How Rust frameworks actually work

Web frameworks in Python or JavaScript hand you a request object and a response object. You read headers, mutate the response, and send it back. Shared state lives in a global dictionary or a closure. Rust refuses to let you mutate shared state across threads. Instead, frameworks enforce a strict pipeline. The server receives raw bytes, parses them into a request, extracts exactly what your handler needs, runs your logic, and constructs a fresh response. Nothing is mutated in place.

Think of it like an assembly line. The raw material arrives at the start. Each station only grabs the specific component it needs, does its job, and passes the result forward. If a station tries to grab something it doesn't have permission to touch, the line stops before a single product is made. Rust frameworks bake this separation into the type system. You get compile-time guarantees instead of runtime panics. The tradeoff is that you learn a new vocabulary: extractors, responders, middleware, and async runtimes. Once the mental model clicks, the compiler becomes a safety net instead of a wall.

Axum: the type-driven router

Axum is built on Tokio, the most widely used async runtime in Rust. It treats routing and request handling as a series of type transformations. You define a handler function. The framework inspects its parameters. Each parameter becomes an extractor that pulls data from the request. If the request lacks the required data, the framework returns a 400 Bad Request before your code ever runs.

use axum::{routing::get, Router};

/// Serves the root endpoint with a plain text response.
#[tokio::main]
async fn main() {
    // Build a router that maps paths to handler functions.
    let app = Router::new()
        .route("/", get(|| async { "Hello, Axum!" }))
        .route("/users", get(|| async { "User list" }));

    // Bind to all interfaces on port 3000.
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    // Hand the router to the server and block until shutdown.
    axum::serve(listener, app).await.unwrap();
}

The framework does not guess what you want. It reads the function signature. If you change a parameter from String to u32, the compiler forces you to update the route. If you add a database pool parameter, Axum looks for a State extractor. You wrap your shared resources in a State struct, inject it at the top level, and every handler that asks for it gets a reference. The borrow checker guarantees no two handlers mutate the pool simultaneously.

Middleware in Axum follows the tower ecosystem. You stack layers like tower::limit::RateLimitLayer or tower_http::trace::TraceLayer. Each layer wraps the next one. Requests flow down, responses flow up. You compose them at compile time. The community convention is to keep middleware close to the router definition rather than scattering it across handlers. It keeps the request lifecycle visible in one place.

Don't fight the type system here. Let the extractors do the heavy lifting.

Actix-web: the throughput engine

Actix-web predates Axum and carries a different architectural philosophy. It was designed around the actor model and its own async runtime. Each connection gets a lightweight task. The framework routes requests through a highly optimized dispatch loop. Benchmarks consistently show Actix-web handling more requests per second than its competitors, especially under heavy concurrent load.

use actix_web::{web, App, HttpResponse, HttpServer, Responder};

/// Returns a simple greeting for the root path.
async fn index() -> impl Responder {
    // Construct a 200 OK response with a text body.
    HttpResponse::Ok().body("Hello, Actix-web!")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // Create a server that spawns a new App instance per worker thread.
    HttpServer::new(|| {
        App::new().route("/", web::get().to(index))
    })
    // Bind to localhost on port 8080.
    .bind("127.0.0.1:8080")?
    // Start the event loop and block until termination.
    .run()
    .await
}

Actix uses web::get().to(index) to bind routes. The web module contains extractors like web::Json<T>, web::Path<T>, and web::Data<T>. Data is Actix's version of shared state. You call .app_data(web::Data::new(pool)) on the App. The framework clones the Data wrapper cheaply and injects it into handlers. The underlying pool stays on the heap. The clone only copies a pointer and an atomic reference count.

The actor model background means Actix excels at keeping connections alive and multiplexing them efficiently. WebSocket servers, long-polling endpoints, and high-frequency trading gateways often choose Actix for this reason. The tradeoff is a steeper learning curve. The routing syntax feels more imperative. The documentation assumes you understand async task scheduling. You will spend time reading about System, Arbiter, and Context if you dig into the internals.

The community convention is to keep HttpServer::new closures cheap. Do not open database connections inside the closure. Open them once, wrap them in web::Data, and share. The server spawns one closure per worker thread. Expensive initialization inside it multiplies your resource usage.

Measure before you optimize. Raw throughput rarely solves architecture problems.

Rocket: the convention-heavy shortcut

Rocket takes a different path. It leans heavily on procedural macros to generate boilerplate. You annotate functions with #[get("/")] or #[post("/users", format = "json")]. The macro inspects the signature, generates the routing table, and wires up validation. You write less glue code. You get built-in session management, fairing hooks, and template rendering out of the box.

use rocket::get;

/// Handles GET requests to the root path.
#[get("/")]
fn index() -> &'static str {
    // Return a static string that lives for the entire program lifetime.
    "Hello, Rocket!"
}

/// Configures and launches the Rocket application.
#[launch]
fn rocket() -> _ {
    // Build the app and mount the generated routes to the root path.
    rocket::build().mount("/", routes![index])
}

Rocket's macros handle type conversion automatically. If a route expects id: u32 and the URL contains abc, Rocket returns a 404 or 400 without invoking your function. The routes![index] macro collects handlers into a slice that the router consumes at startup. You do not manually wire paths to functions. The framework does it for you.

The convenience comes with compile-time friction. Macro errors in Rust are notoriously opaque. A missing trait bound or a mismatched lifetime inside a #[get] annotation often produces a wall of generated code in the error message. You learn to read the first few lines, locate the original annotation, and fix the signature. Recent versions of Rocket have improved error reporting, but the learning curve remains steeper for debugging than for writing.

Rocket also uses its own async runtime under the hood. It abstracts away Tokio or async-std. This means you cannot easily drop in third-party middleware that expects a specific runtime. You stay within Rocket's ecosystem. The community convention is to keep handlers thin. Offload heavy parsing, database queries, and business logic to separate modules. The macro-generated routing layer should only coordinate, not compute.

Treat macro errors as clues, not verdicts. Read the original annotation first.

Pitfalls and compiler signals

Every framework will hand you compiler errors if you break the rules. Axum rejects handlers that ask for extractors the request cannot satisfy. You will see E0277 (trait bound not satisfied) when you forget to implement FromRequest for a custom type. Actix rejects routes that return types not implementing Responder. You will see E0277 again when you return a raw String instead of wrapping it in HttpResponse::Ok().body(). Rocket rejects macro annotations that clash with its routing rules. You will see E0308 (mismatched types) when a path parameter expects u64 but the URL provides a string.

The borrow checker also guards shared state. If you try to mutate a database pool across concurrent handlers without synchronization, you hit E0502 (cannot borrow as mutable because it is also borrowed as immutable). The fix is almost always Arc<Mutex<T>> or an async-aware lock like tokio::sync::Mutex. Do not bypass it with unsafe. The race condition will survive to production.

Convention aside: cargo fmt formats every file identically. Do not argue indentation or brace placement in code reviews. Argue logic, extractors, and error handling. The formatter handles the rest.

Trust the borrow checker. It usually has a point.

When to pick which

Use Axum when you want type-driven routing, seamless Tokio integration, and a middleware ecosystem that composes at compile time. Use Axum when your team values explicit signatures, predictable error messages, and a gentle learning curve for developers coming from other languages. Use Axum when you plan to build long-lived services that need observability, rate limiting, and tracing layers from the tower ecosystem.

Use Actix-web when you have measured throughput bottlenecks that require the absolute fastest request dispatch. Use Actix-web when you are building WebSocket servers, long-polling endpoints, or high-concurrency gateways where connection multiplexing matters more than developer ergonomics. Use Actix-web when your team is comfortable with a more imperative routing style and wants a framework that has been battle-tested in production since 2017.

Use Rocket when you need to ship a prototype quickly and prefer convention over configuration. Use Rocket when you want built-in session management, template rendering, and automatic validation without wiring third-party crates. Use Rocket when your team values concise handler signatures and is willing to trade macro debugging complexity for less boilerplate.

Reach for plain standard library std::net::TcpListener and hyper only when you need zero-framework control over every HTTP byte. The maintenance cost is rarely worth it for application logic.

Where to go next