Why Axum Is the Best Web Framework for Rust
Axum is mostly Tokio, Hyper and Tower, with routing and extractors on top. Why that makes it my default for Rust backends, how extractors, errors and tests work in practice, and where it still falls short.
Every Rust web framework I have used can serve JSON, route a path and talk to Postgres. That part is solved. What separates them is everything around the handler: how you write middleware, how errors become responses, how you test without opening a port, and how much of the framework you have to learn before the code you write looks like ordinary Rust.
I maintain paginator-rs, a pagination library with integrations for Axum, Actix Web and Rocket. Writing the same feature three times, once per framework, is the most honest comparison I have done. Axum is the one where my code looked the most like plain Rust and the least like the framework. It is my default for every new Rust backend, and this post is why.
Axum is mostly not Axum
Axum is small on purpose. It is maintained by the Tokio team, it runs on Hyper for HTTP, and its middleware is Tower. Axum itself is mostly routing and extractors. Everything else is borrowed from crates that much of the async Rust ecosystem already depends on.
That sounds like an implementation detail until you look at what it buys you. A Tower Layer written for Axum also works for a tonic gRPC server, and tonic itself depends on Axum for its router. Timeouts, tracing, compression, CORS, request IDs and static files all come from tower-http, so you configure them the same way in every service:
pub fn app(state: AppState) -> Router {
Router::new()
.route("/users", post(create_user))
.route("/users/{id}", get(get_user))
.route("/me", get(me))
.layer(middleware::from_fn(request_timer))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive())
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(10),
))
.with_state(state)
}
None of those layers are Axum features. When I learned them, I learned them for every Tower-based service I will ever write, not for one framework.
The numbers point the same way. At the time of writing, crates.io shows about 504 million downloads for axum and 127 million in the last 90 days, against 84 million and 11 million for actix-web. A lot of that is transitive, because tonic and Loco both build on Axum, but that is the point: the ecosystem has settled on it as the layer other things are built on.
Handlers are just async functions
An Axum handler is an async fn. Its arguments say what it needs from the request, and its return type says what it sends back:
pub async fn get_user(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Result<Json<User>, AppError> {
state
.users
.find(id)
.map(Json)
.ok_or(AppError::NotFound("user"))
}
There is no attribute macro on the function, no route string next to it and no special return type. The path lives in the router, so a handler can be called, reused and tested like any other function.
Each argument is an extractor, a type that knows how to build itself from a request. State gives you shared state with its type checked at compile time. If get_user asks for State<AppState> and the router was given a different state, the program does not compile. Forgetting to register a dependency is a build error, not a 500 at runtime.
Your own extractors are plain traits
This is where my pagination library made the difference concrete. In Axum, a custom extractor is one trait implementation. Since Axum 0.8 the trait uses native async fn, so there is no #[async_trait] either. Here is an extractor that reads the bearer token:
pub struct CurrentUser {
pub id: String,
}
impl<S: Send + Sync> FromRequestParts<S> for CurrentUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let token = parts
.headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
Ok(CurrentUser {
id: token.to_owned(),
})
}
}
pub async fn me(user: CurrentUser) -> String {
format!("hello, {}", user.id)
}
Any handler that takes CurrentUser is now authenticated, and a handler that does not take it cannot accidentally read the user.
Rocket's version of the same idea needs #[rocket::async_trait] and its own Outcome type. Actix Web's extractors are fine, but per-request logic that wraps the handler means writing middleware, and an Actix middleware is a Transform that builds a Service, with a LocalBoxFuture and forward_ready! in between. The one in paginator-rs is about 45 lines before it does anything. The Axum equivalent is a function:
pub async fn request_timer(request: Request, next: Next) -> Response {
let started = Instant::now();
let path = request.uri().path().to_owned();
let response = next.run(request).await;
tracing::info!(%path, elapsed = ?started.elapsed(), "handled");
response
}
Register it with middleware::from_fn(request_timer) and you are done. When you do need a full Tower Service, for example to publish a layer as a library, the trait is there. You just do not need it for the common case.
Validation is an extractor too
The pattern I use in every Axum project is a Validated<T> extractor. It parses the JSON body, runs the validator rules and turns any failure into my own error type:
pub struct Validated<T>(pub T);
impl<T, S> FromRequest<S> for Validated<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Json(value) = Json::<T>::from_request(req, state)
.await
.map_err(|rejection| AppError::Validation(rejection.body_text()))?;
value
.validate()
.map_err(|errors| AppError::Validation(errors.to_string()))?;
Ok(Validated(value))
}
}
The handler then only ever sees input that has already passed its rules:
#[derive(Deserialize, Validate)]
pub struct CreateUser {
#[validate(length(min = 1, max = 100))]
name: String,
#[validate(email)]
email: String,
}
pub async fn create_user(
State(state): State<AppState>,
Validated(input): Validated<CreateUser>,
) -> Result<(StatusCode, Json<User>), AppError> {
let user = state
.users
.insert(input.name, input.email)
.ok_or(AppError::Unavailable)?;
Ok((StatusCode::CREATED, Json(user)))
}
FromRequest is the extractor trait for things that consume the body, and FromRequestParts is for everything else. The split exists because a body can only be read once, and the compiler enforces it, which brings us to the part of Axum people complain about most.
Errors are values, all the way to the response
Anything that implements IntoResponse can be returned from a handler. That includes your error type, so a handler returns Result<T, AppError> and uses ? like any other Rust function:
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("{0} not found")]
NotFound(&'static str),
#[error("{0}")]
Validation(String),
#[error("unauthorized")]
Unauthorized,
#[error("service unavailable")]
Unavailable,
#[error(transparent)]
Internal(#[from] std::io::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
AppError::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(json!({ "message": self.to_string() }))).into_response()
}
}
The match is exhaustive. Add a variant and every place that maps errors to status codes stops compiling until you decide what the new error means over HTTP. There is one place where errors become responses, and the type system keeps it complete.
Testing without a server
A Router is a Tower Service, so you can send it a request directly. No port, no HTTP client, no waiting for a server to start:
#[tokio::test]
async fn unknown_user_is_404() {
let response = app(state())
.oneshot(Request::get("/users/42").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
These tests go through the real router, extractors, middleware and error mapping, and they finish in milliseconds. Every snippet in this post comes from a small crate I compiled against Axum 0.8.9, and its tests check the 404, the 422 for an invalid email, the 401 without a token and a create-then-fetch round trip. The whole suite runs in under ten milliseconds.
Where Axum falls short
Axum is my default, not a framework without costs.
Handler errors can be hard to read. If an extractor is in the wrong place, for example Json before Path, the compiler only says your function does not implement Handler:
error[E0277]: the trait bound `fn(Json<Input>, Path<u64>) -> ... {update}: Handler<_, _>` is not satisfied
The fix is to add #[axum::debug_handler] from the macros feature while you work on the handler. It turns the same mistake into a message that tells you exactly what is wrong:
error: `Json<_>` consumes the request body and thus must be the last argument to the handler function
It is still 0.x. Axum 0.8 changed path parameters from /:id to /{id} and moved extractors to native async traits. The upgrades were mechanical, but they were breaking changes, and more may come before 1.0.
Some extractors are thinner than you expect. The built-in Query uses serde_urlencoded, which cannot collect repeated keys like ?filter=a&filter=b into a Vec. paginator-rs parses the query string itself for exactly this reason. axum-extra has a Query that handles it, but you have to know to look there.
No batteries. Axum gives you routing, extractors and responses. Auth, sessions, an ORM, migrations, background jobs and configuration are your choice. I prefer that, because it means I pick the crates and know why each one is there. If you want a framework that makes those choices for you, Loco is a Rails-style framework built on Axum, so you are not leaving the ecosystem.
What about the alternatives
Actix Web is mature, fast and stable at 4.x. It runs on its own actor runtime, actix-rt, which starts single-threaded Tokio runtimes per worker, and its middleware and service model is its own rather than Tower's. If you already have a large Actix codebase there is no reason to rewrite it. For a new service, I would rather learn Tower once.
Rocket has the nicest syntax of the three, with routes declared as attributes on the handler. But its last release, 0.5.1, was in May 2024, and its extractor and responder traits are its own. That is a lot of project-specific surface for a framework that moves this slowly.
Poem, Salvo and Warp all work, and each has good ideas. None of them has the ecosystem pull that comes from being built by the same team as Tokio and Hyper.
How I start an Axum project
The framework is small, so the structure is up to you. Mine is the same in every project:
- One crate per feature module, each split into
domain,applicationandinfrastructure. Handlers, routes and DTOs live ininfrastructure/httpand nowhere else. - Repository traits in the domain layer, implemented in
infrastructure/persistence, injected asArc<dyn Trait>throughState. - One use case per file, called from a handler that only extracts, delegates and maps the result.
- A single
AppErrorcrate that every module returns, with the oneIntoResponseimplementation above. Validated<T>for every request body, so a handler never sees unvalidated input.- Tests that call the router with
oneshot, so the HTTP layer is tested as it actually runs.
Every one of those rules is ordinary Rust, enforced by ordinary types. That is what I mean when I say Axum is the best web framework for Rust. It is not the one with the most features. It is the one that asks you to learn the least framework, and rewards you for knowing Rust, Tokio and Tower instead.