How the three main Rust web frameworks compare on routing, extractors, middleware, state, runtime model and ecosystem, with the same endpoint written in each.
Axum, Actix Web and Rocket are the three Rust web frameworks most teams shortlist, and all three run async handlers on Tokio with Serde for JSON. They differ in how routes are declared, how state and middleware are passed around and which ecosystem of crates they plug into, and those differences show up once a service grows past a few handlers and needs shared database pools, auth middleware and tracing that work the same way across every route.
The main differences:
Encore combines the Axum service you already have with infrastructure declared as static values in the same crate, as an SDK you add rather than a framework you switch to, so the router, extractors and Tower layers stay as they are while Postgres databases, Pub/Sub topics, object storage buckets, Redis and cron jobs become declarations that Encore provisions. encore run starts the database, topics and buckets locally and applies migrations, cron jobs run only where the app is deployed, and a deploy provisions them in your own AWS or GCP account, which removes the State plumbing that the frameworks below use for pools and clients.

The signup endpoint that the framework sections below write to Postgres looks like this with Encore and Axum, where it also publishes an event:
use axum::{http::StatusCode, routing::post, Json, Router};
use encore::pubsub::Topic;
use encore::storage::postgres::Database;
use serde::{Deserialize, Serialize};
static DB: Database = Database::new("users").migrations("./migrations");
static SIGNUPS: Topic<Signup> = Topic::new("signups");
#[derive(Serialize, Deserialize)]
struct Signup {
email: String,
}
async fn create_user(Json(req): Json<Signup>) -> Result<StatusCode, StatusCode> {
DB.execute("INSERT INTO users (email) VALUES ($1)", &[&req.email])
.await
.map_err(internal)?;
SIGNUPS.publish(&req).await.map_err(internal)?;
Ok(StatusCode::CREATED)
}
fn internal(_: encore::Error) -> StatusCode {
StatusCode::INTERNAL_SERVER_ERROR
}
#[tokio::main]
async fn main() {
encore::initialize();
let app = Router::new().route("/users", post(create_user));
let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into());
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(encore::shutdown::signal())
.await
.unwrap();
}
Reads use query_one and query_opt on the same declaration, and the row values come out with get:
async fn user_name(id: i64) -> Result<String, encore::Error> {
let row = DB
.query_one("SELECT name FROM users WHERE id = $1", &[&id])
.await?;
Ok(row.get("name"))
}
static declarations that handlers use directly.encore run starts the infrastructure locally, and encore test gives each test its own migrated database.encore::shutdown::signal() plugs into Axum's graceful shutdown so in-flight work drains before exit.Database::new static in the same change and runs it under encore run, with no Terraform to touch.Encore works with whichever of the three frameworks you pick, and Axum is the example used here. Rust support is in beta, and you can sign up for access.
AxumAxum comes from the Tokio project, and a handler is an async function whose arguments are extractors and whose return value implements IntoResponse.
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use serde::Deserialize;
use sqlx::PgPool;
#[derive(Deserialize)]
struct Signup {
email: String,
}
async fn create_user(
State(pool): State<PgPool>,
Json(req): Json<Signup>,
) -> Result<StatusCode, StatusCode> {
sqlx::query("INSERT INTO users (email) VALUES ($1)")
.bind(&req.email)
.execute(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::CREATED)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let app = Router::new()
.route("/users", post(create_user))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
State.State is checked at compile time, so a handler cannot ask for state the router does not provide.Compiler errors for handlers with a wrong extractor order can be hard to read, which #[debug_handler] from axum-macros improves. Axum 0.8 changed path parameters from /:id to /{id}.
Actix WebActix Web declares routes with attribute macros, passes shared state as web::Data and starts a number of workers that each run a single-threaded runtime.
use actix_web::{post, web, App, HttpResponse, HttpServer, Responder};
use serde::Deserialize;
use sqlx::PgPool;
#[derive(Deserialize)]
struct Signup {
email: String,
}
#[post("/users")]
async fn create_user(pool: web::Data<PgPool>, req: web::Json<Signup>) -> impl Responder {
match sqlx::query("INSERT INTO users (email) VALUES ($1)")
.bind(&req.email)
.execute(pool.get_ref())
.await
{
Ok(_) => HttpResponse::Created().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let pool = PgPool::connect(&url).await.expect("connect");
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(pool.clone()))
.service(create_user)
})
.bind(("0.0.0.0", 8080))?
.run()
.await
}
The App factory closure runs once per worker, so shared state has to be cloned into it or wrapped in web::Data, and Actix middleware does not interoperate with Tower layers, which means fewer shared crates with the Axum ecosystem.
RocketRocket puts the method, path and body format in the route attribute, validates input through request guards and has its own configuration and database pool integration through rocket_db_pools, which the signup below uses to write to Postgres.
#[macro_use]
extern crate rocket;
use rocket::http::Status;
use rocket::serde::{json::Json, Deserialize};
use rocket_db_pools::{sqlx, Connection, Database};
// The URL comes from Rocket.toml under [default.databases.users].
#[derive(Database)]
#[database("users")]
struct Users(sqlx::PgPool);
#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
struct Signup {
email: String,
}
#[post("/users", data = "<req>")]
async fn create_user(mut db: Connection<Users>, req: Json<Signup>) -> Result<Status, Status> {
sqlx::query("INSERT INTO users (email) VALUES ($1)")
.bind(&req.email)
.execute(&mut **db)
.await
.map_err(|_| Status::InternalServerError)?;
Ok(Status::Created)
}
#[launch]
fn rocket() -> _ {
rocket::build().attach(Users::init()).mount("/", routes![create_user])
}
Rocket.toml handles configuration per profile.rocket_db_pools manages database pools, and fairings hook into the request lifecycle.Rocket ships releases less often than Axum and Actix Web, and its macro-generated code can make custom patterns harder to express, so check current release activity before committing a large codebase to it.
All three frameworks serve far more requests per second than most APIs receive. Database queries, serialization of large payloads and calls to other services usually decide the real response time, so ergonomics and ecosystem fit tend to matter more than raw throughput when choosing.
Rust support in Encore is in beta, and you can sign up for access if you want to try it with the framework you already use.
Axum is the usual default for new projects because it builds on Tokio, Hyper and Tower and reuses their middleware ecosystem. Actix Web fits teams that want a mature framework with many first-party features, and Rocket fits teams that prefer concise macro-driven routes and request guards.
Actix Web often scores slightly higher in synthetic benchmarks, but both handle far more requests than a typical API needs, and database and network calls dominate real response times.
Yes. Rocket 0.5 runs on stable Rust and handlers can be async functions running on Tokio.
Not directly. Tower layers plug into Axum and other Hyper-based frameworks, while Actix Web has its own middleware trait and Rocket uses fairings.
Encore is an infrastructure SDK you add to whichever of these frameworks you pick, with Axum as the example here. The framework keeps routing and extraction, and Postgres databases, Pub/Sub topics, buckets and cron jobs are static declarations in the crate. encore run starts the database, topics and buckets locally, cron jobs run only where the app is deployed, and a deploy provisions everything in your own AWS or GCP account. Rust support is in beta.
Add the encore-dev crate to the existing Axum service, call encore::initialize() before serving, and declare Database::new and Topic::new as static values that handlers use directly. encore run starts them locally and a deploy provisions them in your own AWS or GCP account, and the same SDK works with whichever framework you pick.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.