Introduction & Architecture
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 (axum-core 0.5.6, axum-extra 0.12.6, axum-macros 0.5.1) on Rust 1.99 with the 2024 edition, tokio 1.53, tower 0.5 and tower-http 0.7, checked on 2026-10-06. axum’s minimum supported Rust version is 1.80. Where axum 0.7 behaved differently the difference is called out; changes already merged for the unreleased 0.9 are flagged as upcoming.
Why Rust for backend services
A backend service spends its life doing three things: waiting on the network, transforming data, and holding state for many concurrent clients. Rust is a good fit for all three:
-
Predictable latency and low memory. There is no garbage collector, so there are no collection pauses in the tail of the latency distribution, and a typical axum service idles at a few megabytes of resident memory. Containers can be packed densely, and cold starts on serverless platforms (AWS Lambda) are short.
-
Safety without a runtime cost. The ownership and borrowing rules (see Ownership & Moves) rule out use-after-free, double frees and data races at compile time. The
SendandSynctraits extend that to the multi-threaded tokio scheduler: a handler that would share non-thread-safe state across tasks does not compile. -
Expressive types for the request boundary. serde derives turn JSON into typed structs, newtypes make invalid input unrepresentable, and
Resultforces every error path to become a response. -
Throughput. tokio’s work-stealing scheduler and hyper’s HTTP implementation are among the fastest available in any language, so the framework is rarely the bottleneck of a service.
The trade-offs are real: compile times are longer than with Python or Node.js, the trait-heavy APIs produce long
compiler errors, and the ecosystem is younger than Spring’s or Django’s. Yoon (Beginning Axum, chapter 1) collects
public case studies of teams moving services to Rust for latency and cost; the
Incremental Adoption & Refactoring page
discusses when such a move pays off. One claim from that chapter needs correcting: Rust does not catch memory leaks
at compile time. Leaking is memory-safe in Rust — an Rc cycle or std::mem::forget leaks without any unsafe — so long-running services still need memory monitoring.
What axum is
axum is a web application framework maintained by the tokio project. Its crate documentation lists its high-level features, and each of them shapes how you write a service:
-
Macro-free routing. Routes are built with ordinary method calls —
Router::new().route("/books/{isbn}", get(show))— rather than attribute macros on functions. The router is a value you can compose, test and pass around. -
Declarative request parsing with extractors. A handler is an
async fnwhose parameters (Path,Query,Json,State, …) describe what to pull out of the request. If extraction fails, the extractor’s rejection becomes the response, and the handler never runs. -
A simple, predictable error model. Handlers cannot "throw": whatever they return must implement
IntoResponse, so every error is turned into an HTTP response explicitly (see Error Handling). -
Minimal boilerplate for responses. Strings, status codes,
Json,Html, tuples of headers and bodies all implementIntoResponse. -
Full use of the tower and tower-http ecosystem. axum has no middleware system of its own. Timeouts, tracing, compression, CORS, authorisation and rate limiting are tower
Layers, so they work in axum, in tonic (gRPC), in a raw hyper server and inlambda_httpalike.
The last point is the key architectural decision. Everything in axum — the Router, a MethodRouter, a handler once
it is given its state — is a tower::Service<Request>. The Tower Service
Model page explains that trait in detail.
Source: axum: High-level features
The tokio → hyper → tower → axum stack
| Layer | Responsibility |
|---|---|
tokio |
The asynchronous runtime: a work-stealing scheduler that polls futures on a pool of worker threads, non-blocking
sockets ( |
hyper |
A correct, fast HTTP/1 and HTTP/2 implementation. hyper 1.0 (November 2023) removed its built-in server; the
|
http, http-body |
Shared vocabulary types: |
tower |
The |
tower-http |
HTTP-specific tower middleware: tracing, request IDs, CORS, compression, body limits, static files, CSRF protection and more (see tower-http Catalog). |
axum |
Routing, extractors, responses, state, |
You rarely depend on hyper or tower directly in a small application — axum re-exports what you need — but as soon as
you write middleware or test the router in process you meet them by name, so it pays to know where each type comes from.
The request lifecycle
The sequence below follows one GET /api/v1/books/9780441013593 request through Bookshelf. Every other core page
has a short "Where it runs in the request" section that places its topic on this diagram:
-
tokio:
axum::serveaccepts each TCP connection on atokio::net::TcpListenerand spawns a tokio task that drives a hyper connection. -
hyper parses the request head (HTTP/1.1 by default, HTTP/2 when the
http2feature is enabled and the client negotiates it) and hands anhttp::Request<axum::body::Body>to the service it was given. -
tower layers wrapped around the whole
Router(ServiceBuilder::new().layer(...).service(router)orLayer::layer(router)) run before routing, so they can rewrite the URI —NormalizePathLayeris the usual example. -
The
Routermatches the path (with thematchitcrate) and then the HTTP method. No match calls the fallback (404 Not Foundby default); a path match with the wrong method yields405 Method Not Allowed. -
Middleware added with
Router::layer,route_layerorMethodRouter::layerruns after routing, around the matched route. This is where most of an application’s layers live (see Applying Middleware & Ordering); each one sees the request on the way in and the response on the way out. -
Extractors run in parameter order: all
FromRequestPartsextractors first, then at most one body extractor (FromRequest). The first rejection becomes the response. -
The handler runs and returns something that implements
IntoResponse, which is converted into anhttp::Response<Body>. The response travels back out through the middleware and the outer layers to hyper, which writes it to the socket; the body may stream (SSE, file downloads) long after the handler has returned.
Where it runs in the request
This page is the map the other pages refer to: tokio accepts the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware runs around the matched route, extractors run, the handler runs, and the response travels back out the same way. The later pages each pin their topic to one of those steps.
A first look at the code
The smallest useful Bookshelf exposes a health check and one JSON endpoint backed by the shelf-core domain crate
from the Rust Reference:
[package]
name = "bookshelf-web"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.9"
tokio = { version = "1.53", features = ["full"] }
serde = { version = "1", features = ["derive"] }
shelf-core = { path = "../shelf-core", features = ["serde"] }
use std::sync::{Arc, RwLock};
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::get,
};
use shelf_core::{Author, Book, Catalog, InMemoryCatalog, Isbn};
type SharedCatalog = Arc<RwLock<InMemoryCatalog>>;
async fn health() -> &'static str {
"ok"
}
async fn show_book(
State(catalog): State<SharedCatalog>,
Path(isbn): Path<String>,
) -> Result<Json<Book>, StatusCode> {
let isbn = Isbn::parse(&isbn).map_err(|_| StatusCode::BAD_REQUEST)?;
let catalog = catalog.read().expect("catalog lock poisoned");
catalog.get(&isbn).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}
fn app(catalog: SharedCatalog) -> Router {
Router::new()
.route("/health", get(health))
.route("/api/v1/books/{isbn}", get(show_book))
.with_state(catalog)
}
#[tokio::main]
async fn main() {
let mut catalog = InMemoryCatalog::new();
let dune = Isbn::parse("978-0-441-01359-3").expect("valid ISBN");
catalog
.add(Book::new(dune, "Dune", vec![Author::new("Frank Herbert")], 1965))
.expect("empty catalogue");
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.expect("port 3000 is free");
axum::serve(listener, app(Arc::new(RwLock::new(catalog)))).await.expect("server error");
}
Source: axum: Example
$ curl -s localhost:3000/api/v1/books/9780441013593
{"isbn":"9780441013593","title":"Dune","authors":[{"name":"Frank Herbert"}],"year":1965}
$ curl -si localhost:3000/api/v1/books/123 | head -1
HTTP/1.1 400 Bad Request
Source: axum: Example
Every axum concept is already present: a Router built from method routers, two extractors (State and Path), a
Result return type whose both arms implement IntoResponse, shared state, and axum::serve. The lock guard is
dropped before the handler returns and is never held across an .await, which is why a std::sync::RwLock is the right
choice here (see State & Dependency Injection).
The crate map
| Crate | Contents |
|---|---|
|
The framework: |
|
The stable traits other crates implement: |
|
Opt-in extras: |
|
|
The repository also carries dozens of runnable examples at the axum-v0.8.9 tag; the pages of this section link the
relevant ones, and the ecosystem list (ECOSYSTEM.md) points to community crates.
Versioning and release cadence
axum is still at 0.x, and Cargo treats every minor version (0.7 → 0.8) as a breaking release. The pattern so far has
been a breaking release roughly once a year — 0.6 (November 2022), 0.7 (November 2023, with hyper 1.0), 0.8 (January
2025) — with frequent compatible 0.8.x patch releases in between. Pin the minor version (axum = "0.8.9" allows any
0.8.x at or above 0.8.9) and upgrade axum, axum-extra and tower-http together, because they share types.
No 0.9 has been released. The main branch already contains breaking changes for it: nested-router fallbacks are
merged differently, serve applies hyper’s header-read timeout by default and its future’s output type changes, and
new APIs such as ListenerExt::limit_connections, Serve::with_executor and the HTTP QUERY method appear. The
Migration & Framework Comparison page collects the 0.6 → 0.7
→ 0.8 migrations and these upcoming items.
Where axum fits
axum sits in the middle of the Rust web spectrum: lower-level than a batteries-included framework, higher-level than writing hyper services by hand.
-
actix-web 4 has its own actor-derived runtime layer and middleware system, and a long track record; it does not share tower middleware.
-
Rocket 0.5 uses attribute macros (
#[get("/")]) and request guards and emphasises ergonomics; its release cadence has slowed. -
Loco is a Rails-like framework built on axum, with generators, an ORM (SeaORM), background workers and mailers — a good choice when you want conventions rather than a toolkit.
-
Leptos and Dioxus use axum as the server for full-stack Rust applications (see Full-stack with Leptos & Dioxus).
If you know other stacks: an axum handler is close to a FastAPI path operation (typed parameters, a function per route), the extractor model resembles NestJS pipes and parameter decorators, and tower layers play the role of ASP.NET Core middleware or Spring filters. The comparison table on the Migration & Framework Comparison page goes into detail.
This section does not re-explain async Rust: read
Async/Await & Futures and
Async Runtimes & Tokio first if .await, Send futures
and tokio::spawn are new to you.
Where the books are outdated
Crossley et al. (Rust for Scientific Computing, ch. 15, which the Rust Reference uses for topic coverage only)
builds web examples on axum::Server and AddExtensionLayer; both are gone. Since axum 0.7,
axum::serve(listener, router) replaces axum::Server::bind(...).serve(...), and shared data is passed with
State (or the Extension extractor/layer). Yoon (Beginning Axum) targets axum 0.8 and is current on the core API,
but uses tower-http 0.6; the tower-http 0.7 changes are covered on the middleware pages.