If you’re looking to build a web API that’s blisteringly fast and doesn’t crash, Rust is a fantastic place to start. I remember first trying to use Rust for a web project. The sheer speed and the confidence the compiler gives you were a revelation. Today, I want to walk you through the landscape of Rust web frameworks. It’s not about finding the single best one, but about finding the right tool for your specific job. We’ll look at eight different options, from the robust and mature to the sleek and new.
Let’s begin with Axum. Think of Axum as the framework that plays nicely with everyone else in the Tokio ecosystem. It doesn’t try to own everything. Instead, it builds directly on top of Hyper, the low-level HTTP library, and Tower, a library for building reusable components. This design makes it incredibly modular. You can plug in exactly what you need. I find its way of handling requests, through a system called “extractors,” to be both elegant and powerful. It lets you declaratively pull data out of incoming requests.
Here’s a small taste of Axum. You define a router and tell it which functions handle which paths. The Path extractor pulls the :id segment from the URL for you automatically.
use axum::{routing::get, Router, extract::Path, Json};
use serde::Serialize;
#[derive(Serialize)]
struct ApiResponse {
user_id: u32,
status: String,
}
async fn fetch_item(Path(item_id): Path<u32>) -> Json<ApiResponse> {
// Your logic to fetch the item would go here.
Json(ApiResponse {
user_id: item_id,
status: "found".to_string(),
})
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/item/:id", get(fetch_item));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Next, we have Rocket. Rocket feels different. It’s designed for maximum developer happiness, using Rust’s macro system to reduce repetitive code. Writing a route in Rocket often feels like writing a simple function with annotations. It has its own runtime, which means it handles a lot of the async complexity for you. This can be a blessing when you’re starting out, as it simplifies the setup process. The safety features are baked in; for example, your route won’t compile if the data it expects isn’t valid.
This example shows how concise it can be. The macro handles the routing, and parameters from the path are simply arguments to your function.
#[macro_use] extern crate rocket;
#[get("/greet/<username>")]
fn greet_user(username: &str) -> String {
format!("Welcome back, {}.", username)
}
#[launch]
fn start_server() -> _ {
rocket::build().mount("/", routes![greet_user])
}
Actix Web is a veteran with a reputation for raw speed. It was one of the first major players and has powered some very high-traffic services. Its API is comprehensive and feature-rich. While its early actor-model foundation has evolved, it retains a focus on performance and fine-grained control. I often recommend it for projects where you need a proven, battle-tested foundation and expect to use advanced features like WebSockets or complex streaming responses.
Setting up a basic route is straightforward. The web::Path extractor works similarly to others, giving you access to dynamic segments.
use actix_web::{get, web, App, HttpServer, Responder};
#[get("/profile/{user_id}")]
async fn show_profile(path: web::Path<u32>) -> impl Responder {
let id = path.into_inner();
format!("Displaying profile for user #{}", id)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new().service(show_profile)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
Now, let’s talk about Warp. Warp adopts a functional programming style. Instead of a router object, you build your application by combining small, reusable pieces called “filters.” A filter is essentially a function that processes a request and either passes it along or returns a response. This approach is highly composable. You can build complex request-handling logic by chaining simple filters together. It’s incredibly flexible and runs on top of Hyper and Tokio.
This is the Warp way. You describe what you want to match and what you want to do in a fluent, chainable style.
use warp::Filter;
#[tokio::main]
async fn main() {
// A filter that matches the path /api/health and returns "OK"
let health_check = warp::path!("api" / "health")
.map(|| "OK");
// A filter that echoes a name from the path
let hello = warp::path!("hello" / String)
.map(|name| format!("Good day, {}", name));
// Combine the filters into a single service
let routes = health_check.or(hello);
warp::serve(routes)
.run(([127, 0, 0, 1], 3030))
.await;
}
Poem is a modern contender that places a strong emphasis on API design. Its standout feature is integrated OpenAPI support. You can write your API endpoints, and Poem will automatically generate a full OpenAPI specification and an interactive Swagger UI page. This is a massive productivity boost for teams that need to document and share their API contract. It treats the OpenAPI spec as a core part of the framework, not an afterthought.
In Poem, you define your API as a struct with methods. The OpenApi macro and attributes handle the rest.
use poem::{listener::TcpListener, Route, Server};
use poem_openapi::{OpenApi, OpenApiService, payload::Json};
use serde::Serialize;
#[derive(Serialize)]
struct HealthStatus {
service: String,
version: String,
ok: bool,
}
struct ProjectApi;
#[OpenApi]
impl ProjectApi {
#[oai(path = "/health", method = "get")]
async fn check_health(&self) -> Json<HealthStatus> {
Json(HealthStatus {
service: "my_api".to_string(),
version: "1.0.0".to_string(),
ok: true,
})
}
}
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let api_service = OpenApiService::new(ProjectApi, "Project API", "1.0")
.server("http://localhost:3000");
let ui = api_service.swagger_ui(); // Auto-generated docs UI
// Mount the API and the docs UI
let app = Route::new().nest("/", api_service).nest("/docs", ui);
Server::new(TcpListener::bind("0.0.0.0:3000"))
.run(app)
.await
}
Salvo aims to be a complete, integrated solution. It provides its own runtime, router, middleware system, and even templating support. The goal is simplicity through cohesion. If you want a framework that gives you a consistent set of tools for the entire application, from routing to rendering, Salvo is worth considering. It tries to reduce the cognitive load of choosing and wiring together many separate libraries.
The structure will feel familiar if you’ve used other web frameworks. You define handlers and attach them to a router.
use salvo::prelude::*;
#[handler]
async fn list_users() -> &'static str {
"Here is a list of users."
}
#[handler]
async fn create_user() -> &'static str {
"Creating a new user."
}
#[tokio::main]
async fn main() {
// Build a router with multiple routes and HTTP methods
let router = Router::new()
.get(list_users)
.post(create_user);
Server::new(TcpListener::bind("0.0.0.0:7878"))
.serve(router)
.await;
}
Tide offers a different foundation. It’s built for the async-std runtime, which is an alternative to Tokio. Its API is minimalist and focuses on a straightforward request/response model. The middleware system is simple, based on implementing a trait. If your project is already using async-std, or if you prefer its model, Tide provides a natural web framework choice for that ecosystem. It feels lean and purpose-built.
Tide’s handlers are simple async functions that take a Request and return a Result.
use tide::Request;
use serde_json::json;
async fn handle_api_request(mut req: Request<()>) -> tide::Result<String> {
// You can access query parameters, body, etc., from `req`
let body: serde_json::Value = req.body_json().await?;
let response = json!({
"received": body,
"message": "Request processed"
});
Ok(response.to_string())
}
#[async_std::main]
async fn main() -> tide::Result<()> {
let mut app = tide::new();
app.at("/api/data").post(handle_api_request);
app.listen("127.0.0.1:8080").await?;
Ok(())
}
Finally, Pavex represents an interesting new direction. It’s still emerging, but its core idea is compelling: move as much work as possible to compile time. You don’t write your routes directly in your main application code. Instead, you define them in a dedicated configuration. Pavex then uses procedural macros to generate the actual routing code. This can potentially eliminate runtime routing overhead and catch mismatches between your paths and your handler function signatures at compile time, which is very much in the Rust spirit of catching errors early.
Because of its unique build-time approach, a simple example looks different. You typically work with a pavex.toml file and separate handler modules. The generated code is then integrated into your project.
While I can’t show a full Pavex project here easily, the conceptual workflow is key: you declare your application’s blueprint, and the framework builds the optimized glue for you.
So, how do you choose? It depends on your priorities. Are you deeply invested in the Tokio ecosystem and want maximum flexibility? Look at Axum or Warp. Do you value rapid development and strong defaults? Rocket or Salvo might be your pick. Do you need industrial-grade performance and a vast feature set? Actix Web is a solid choice. Is automatic API documentation a top priority? Poem excels there. Are you building on async-std? Tide is your natural partner. Interested in pushing type safety and compile-time checks to the limit? Keep an eye on Pavex.
I suggest starting a small, throwaway project with two or three that sound interesting. Build the same simple API with each. You’ll quickly learn which framework’s style and patterns fit your way of thinking. The great thing about Rust’s ecosystem is that all these frameworks deliver on the core promise: speed you can feel and reliability you can trust.