Best Rust web frameworks to use

Amid the wave of changes in web development, Rust has become the language of choice for building secure and performant applications. As Rust’s popularity grows, so does a range of web frameworks designed to take advantage of it. This article compares some of the best Rust frameworks, highlighting their respective strengths and weaknesses to help you make informed decisions for your projects. It also requires keeping an eye on frameworks that need attention because they might change the way we build web applications in Rust.

Since most web frameworks look very similar in use at first glance, the actual differences are more subtle and trivial. I hope to highlight the most important differences in the text, but to give you a better idea, I also show example code for each framework that does more than just simple hello world. All examples are taken from their respective GitHub repositories.

Also note that this list is by no means exhaustive and I’ve definitely missed some existing frameworks. If you want your favorite frame included, please contact me on Twitter or Mastodon.

Popular Rust framework

Axum

Axum is a web application framework with a special place in the Rust ecosystem. It is part of the Tokio project, a runtime for writing asynchronous web applications using Rust. Axum not only uses Tokio as its asynchronous runtime, but also integrates with other libraries in the Tokio ecosystem, leverages Hyper as its HTTP server, and uses Tower as middleware. This way, developers can reuse existing libraries and tools from the Tokio ecosystem.

Axum also strives to provide a first-class developer experience without relying on macros, instead leveraging Rust’s type system to provide a safe and ergonomic API. This is achieved by using traits to define the core abstractions of the framework, e.g.Handler Traits, which are used to define the core logic of the application. This approach allows developers to easily compose applications from smaller components that can be reused across multiple applications.

A handler in Axum is a function that accepts a request and returns a response. This is similar to other backend frameworks, but using Axum’s FromRequesttraits, developers can specify the type of data that should be extracted from the request. Return type needs to be implementedIntoResponse the trait, and there are many types that already implement this trait, including tuple types that allow easy changes to, for example, the response’s status code.

If you’ve ever worked with Rust’s type system, generics, and especially the asynchronous methods in traits (or more specifically: the returnedFuture ), you’ll know Rust’s error messages when you don’t satisfy a trait’s bounds How complicated it can get. Especially when you try to fit abstract trait constraints (bound), what often happens is that you end up with a wall of text that is difficult to decipher. Changing the order of a few lines has no effect! Axum provides a library with helper macros that can localize errors to where they actually occur, making it easier to understand what went wrong.

Axum does a lot of things right and makes it easy to launch applications that perform a lot of operations. However, there are a few things you need to be aware of. This version is still below 1.0, and the Axum team can radically change the API between versions, which may cause your application to crash. We know that this is how things work in version 0.x, but some of the changes may seem very subtle but require you to develop a different mental model of how things work under the hood. If you include aTimeout layer (built into Tower) it works easily in one version, requires a catch all error handler in another and a bound error handler in the next. This isn’t a big deal, but it can be frustrating when you’re trying to get work done or start a project with the latest version and things suddenly change. Additionally, while you can take advantage of the entire Tokio ecosystem, sometimes you need to deal with glue types and traits rather than using Tokio functions directly. An example is using anything related to streams and (network) sockets.

Good examples help, but you need to keep tracking.

Nonetheless, Axum is my personal favorite and the framework I use for Shuttle Launchpad. I love the expressiveness of it and the concepts behind it, and by understanding the right concepts, I can solve problems that I couldn’t do intuitively. If you want to learn about Axum concepts, check out the slides from my Tokio + Microservices workshop.

Axum Example

A brief example in the Axum repository shows a WebSocket handler that echoes any messages it receives.

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    println!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app()).await.unwrap();
}

fn app() -> Router {
    // WebSocket routes can generally be tested in two ways:
    //
    // - Integration tests where you run the server and connect with a real WebSocket client.
    // - Unit tests where you mock the socket as some generic send/receive type
    //
    // Which version you pick is up to you. Generally we recommend the integration test version
    // unless your app has a lot of setup that makes it hard to run in a test.
    Router::new()
        .route("/integration-testable", get(integration_testable_handler))
        .route("/unit-testable", get(unit_testable_handler))
}

// A WebSocket handler that echos any message it receives.
//
// This one we'll be integration testing so it can be written in the regular way.
async fn integration_testable_handler(ws: WebSocketUpgrade) -> Response {
    ws.on_upgrade(integration_testable_handle_socket)
}

async fn integration_testable_handle_socket(mut socket: WebSocket) {
    while let Some(Ok(msg)) = socket.recv().await {
        if let Message::Text(msg) = msg {
            if socket
                .send(Message::Text(format!("You said: {msg}")))
                .await
                .is_err()
            {
                break;
            }
        }
    }
}

AxumSummary

  • Macro-free API.
  • Build a powerful ecosystem with Tokio, Tower and Hyper.
  • Great developer experience.
  • Still in version 0.x, so breaking changes may occur.

Actix Web Actix

Actix Web is one of the Rust web frameworks that has been around for a while and is therefore very popular. Like any good open source project, it has gone through many iterations, but it has reached 0+ major versions with a stability guarantee: within a major version, you can be sure that there will be no breaking changes.

When we talk about Actix Web, it’s easy to assume that it is based on actixthe actor runtime. However, this hasn’t happened in over 4 years; the only remaining part of Actix Web that requires actors is WebSocket, but we’re working on removing its use entirely because actix it doesn’t work with modern async Rust it doesn’t play well world. The broader Actix project and GitHub org provide a number of libraries for building concurrent applications, from the lower-level TCP server builder, through the HTTP/Web layer, all the way to static file providers and session management crates.

At first glance, Actix Web looks very familiar to other web frameworks in Rust. You use macros to define HTTP methods and routes (such as Rocket), and extractors to get data from requests (such as Axum). The similarities to Axum are striking, also in the way concepts and features are named. The biggest difference is that Actix Web doesn’t tie itself too closely to the Tokio ecosystem. While Tokio is still a runtime under Actix Web, the framework has its own abstractions and features, as well as its own crate ecosystem. This has advantages and disadvantages. On the one hand, you can be sure that things generally work well together, on the other hand, you may miss out on a lot of things already available in the Tokio ecosystem.

One thing that strikes me as odd is that Actix Web implements its own Service trait, which is basically the same as Tower’s, but is still incompatible. This means that most of the middleware available in the Tower ecosystem does not work with Actix.

It’s also interesting to note that if you need to implement some special tasks yourself in Actix Web, you may come across the Actor model that runs everything in the framework. This may add some complexity that you may not want to deal with.

But the community around Actix Web provides just that. The framework supports HTTP/2 and Websocket upgrades, it has packages and guides for the most common tasks in web frameworks, excellent (and I mean excellent) documentation, and it’s fast. Actix Web is popular for a reason, and if you need to maintain version guarantees, it might be your best option right now.

Actix Web Example

A simple WebSocket echo server in Actix Web looks like this:

use actix::{Actor, StreamHandler};
use actix_web::{web, App, Error, HttpRequest, HttpResponse, HttpServer};
use actix_web_actors::ws;

/// Define HTTP actor
struct MyWs;

impl Actor for MyWs {
    type Context = ws::WebsocketContext<Self>;
}

/// Handler for ws::Message message
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for MyWs {
    fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
        match msg {
            Ok(ws::Message::Ping(msg)) => ctx.pong(&msg),
            Ok(ws::Message::Text(text)) => ctx.text(text),
            Ok(ws::Message::Binary(bin)) => ctx.binary(bin),
            _ => (),
        }
    }
}

async fn index(req: HttpRequest, stream: web::Payload) -> Result<HttpResponse, Error> {
    let resp = ws::start(MyWs {}, &req, stream);
    println!("{:?}", resp);
    resp
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().route("/ws/", web::get().to(index)))
        .bind(("127.0.0.1", 8080))?
        .run()
        .await
}

Actix Web Summary

  • Strong, independent ecosystem.
  • Actor model based.
  • A stable API is guaranteed through major releases.
  • Great documentation.

Rocket

Rocket has long been a star in the Rust web framework ecosystem, with its unapologetic approach to the developer experience, its reliance on familiar, existing concepts, and its ambitious goal of delivering an experience rich in built-in functionality.

When you enter their beautiful website, you can see its ambitions: macro-based routing, built-in form handling, support for databases and state management, and its own version of templates! Rocket really tries to do everything you need to build a web application.

However, Rocket’s ambition came at a cost. While still under active development, releases are not as frequent as before. This means that users of the framework miss a lot of important stuff.

Additionally, because of its rich built-in functionality, you also need to understand how Rocket works. Rocket applications have a life cycle, the building blocks are connected in specific ways, and if something goes wrong, you need to understand what the problem is.

Rocket is a great framework and a good choice if you want to get started with Rust web development. Personally, I have a soft spot for Rocket and hope its development can be accelerated. For many of us, Rocket was the first entry into Rust, and it’s still fun to develop with. Still, I usually rely on functionality that isn’t available in Rocket, so I won’t use it in production.

Rocket Example

A short example of a Rocket app that handles forms from the samples repository:

#[derive(Debug, FromForm)]
struct Password<'v> {
    #[field(validate = len(6..))]
    #[field(validate = eq(self.second))]
    first: &'v str,
    #[field(validate = eq(self.first))]
    second: &'v str,
}

#[derive(Debug, FromForm)]
#[allow(dead_code)]
struct Submission<'v> {
    #[field(validate = len(1..))]
    title: &'v str,
    date: Date,
    #[field(validate = len(1..=250))]
    r#abstract: &'v str,
    #[field(validate = ext(ContentType::PDF))]
    file: TempFile<'v>,
    ready: bool,
}

#[derive(Debug, FromForm)]
#[allow(dead_code)]
struct Account<'v> {
    #[field(validate = len(1..))]
    name: &'v str,
    password: Password<'v>,
    #[field(validate = contains('@').or_else(msg!("invalid email address")))]
    email: &'v str,
}

#[derive(Debug, FromForm)]
#[allow(dead_code)]
struct Submit<'v> {
    account: Account<'v>,
    submission: Submission<'v>,
}

#[get("/")]
fn index() -> Template {
    Template::render("index", &Context::default())
}

// NOTE: We use `Contextual` here because we want to collect all submitted form
// fields to re-render forms with submitted values on error. If you have no such
// need, do not use `Contextual`. Use the equivalent of `Form<Submit<'_>>`.
#[post("/", data = "<form>")]
fn submit<'r>(form: Form<Contextual<'r, Submit<'r>>>) -> (Status, Template) {
    let template = match form.value {
        Some(ref submission) => {
            println!("submission: {:#?}", submission);
            Template::render("success", &form.context)
        }
        None => Template::render("index", &form.context),
    };

    (form.context.status(), template)
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount("/", routes![index, submit])
        .attach(Template::fairing())
        .mount("/", FileServer::from(relative!("/static")))
}

Rocket Summary

  • Rich built-in features.
  • Great developer experience.
  • Not as actively developing as before.
  • Still a good choice for beginners.

Lesser-known but still exciting Rust frameworks

Warp

Hello, Warp! You are a beautiful, strange, powerful beast. Warp is a web framework built on top of Tokio, and it’s a very good framework. It’s also very different from the other frameworks we’ve seen so far.

Warp shares some traits with Axum (haha!): it’s built on Tokio and Hyper, and uses Tower middleware. However, its approach is very different. Warp is built onFilter traits.

In Warp, you build a pipeline of filters that are applied to incoming requests, and the requests are passed through the pipeline until they reach the end. Filters can be chained and combined. This allows you to build very complex but still easy-to-understand pipelines.

Warp is also closer to the Tokio ecosystem than Axum, which means you can work with more Tokio structures and concepts without any glue features.

Warp takes a very pragmatic approach, and if that’s your programming style, you’ll love its expressiveness and composability. When you look at a piece of Warp code, it often reads like a story taking place, which is fun and surprising to see work in Rust.

However, you may want to turn off the type inlay prompt in the RRust Analyzer settings. Because all these different functions and filters are chained together, types in Warp become very long and complex, and also difficult to decipher. The same goes for error messages, which can be an incomprehensible mess of messages.

Also, while the filter concept is great when you’re done, sometimes you want to have the declarative router, handler, and extractor styles you get with all the other frameworks.

Warp is a great framework and I love it. However, it is not the most beginner-friendly framework, nor is it the most popular. This means you may have a hard time finding help and resources. But it’s fun for fast and small applications, and its experimental style might give you new ideas!

Warp Example

Demo example of websocket chat from the samples repository:

static NEXT_USER_ID: AtomicUsize = AtomicUsize::new(1);

/// Our state of currently connected users.
///
/// - Key is their id
/// - Value is a sender of `warp::ws::Message`
type Users = Arc<RwLock<HashMap<usize, mpsc::UnboundedSender<Message>>>>;

#[tokio::main]
async fn main() {
    let users = Users::default();
    // Turn our "state" into a new Filter...
    let users = warp::any().map(move || users.clone());

    // GET /chat -> websocket upgrade
    let chat = warp::path("chat")
        // The `ws()` filter will prepare Websocket handshake...
        .and(warp::ws())
        .and(users)
        .map(|ws: warp::ws::Ws, users| {
            // This will call our function if the handshake succeeds.
            ws.on_upgrade(move |socket| user_connected(socket, users))
        });

    // GET / -> index html
    let index = warp::path::end().map(|| warp::reply::html(INDEX_HTML));

    let routes = index.or(chat);

    warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
}

async fn user_connected(ws: WebSocket, users: Users) {
    // Use a counter to assign a new unique ID for this user.
    let my_id = NEXT_USER_ID.fetch_add(1, Ordering::Relaxed);

    eprintln!("new chat user: {}", my_id);

    // Split the socket into a sender and receive of messages.
    let (mut user_ws_tx, mut user_ws_rx) = ws.split();

    let (tx, rx) = mpsc::unbounded_channel();
    let mut rx = UnboundedReceiverStream::new(rx);

    tokio::task::spawn(async move {
        while let Some(message) = rx.next().await {
            user_ws_tx
                .send(message)
                .unwrap_or_else(|e| {
                    eprintln!("websocket send error: {}", e);
                })
                .await;
        }
    });

    // Save the sender in our list of connected users.
    users.write().await.insert(my_id, tx);

    // Return a `Future` that is basically a state machine managing
    // this specific user's connection.

    // Every time the user sends a message, broadcast it to
    // all other users...
    while let Some(result) = user_ws_rx.next().await {
        let msg = match result {
            Ok(msg) => msg,
            Err(e) => {
                eprintln!("websocket error(uid={}): {}", my_id, e);
                break;
            }
        };
        user_message(my_id, msg, &users).await;
    }

    // user_ws_rx stream will keep processing as long as the user stays
    // connected. Once they disconnect, then...
    user_disconnected(my_id, &users).await;
}

async fn user_message(my_id: usize, msg: Message, users: &Users) {
    // Skip any non-Text messages...
    let msg = if let Ok(s) = msg.to_str() {
        s
    } else {
        return;
    };

    let new_msg = format!("<User#{}>: {}", my_id, msg);

    // New message from this user, send it to everyone else (except same uid)...
    for (&uid, tx) in users.read().await.iter() {
        if my_id != uid {
            if let Err(_disconnected) = tx.send(Message::text(new_msg.clone())) {
                // The tx is disconnected, our `user_disconnected` code
                // should be happening in another task, nothing more to
                // do here.
            }
        }
    }
}

async fn user_disconnected(my_id: usize, users: &Users) {
    eprintln!("good bye user: {}", my_id);

    // Stream closed up, so remove from the user list
    users.write().await.remove(&my_id);
}

Summary

  • Functional approach. Functional approach.
  • Very expressive. Very expressive.
  • Close to Tokio, Tower and Hyper, has a strong ecosystem.
  • Not the best framework for beginners.

Tide

Tide is a very minimalist web framework built on async-stdtop of the runtime. A minimalist approach means you get a very small API surface. The handler function in Tide is that async fnit accepts Requestand Responsereturnstide::Result . It’s up to you to extract the data or send the correct response format.

While this may be more work for you, it’s also more straightforward, meaning you have complete control over what’s going on. For some cases, being able to be so close to HTTP requests and responses is nice and makes things easier.

Its middleware approach is similar to what you know from Tower, but Tide exposes an asynchronous feature box to make implementation easier. Since Tide is implemented by people who are also involved in the Rust async ecosystem, you can expect things like proper async methods in the features that recently landed in Nightly to be adopted soon.

Tide Example

User session example from the examples repository.

#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
    femme::start();
    let mut app = tide::new();
    app.with(tide::log::LogMiddleware::new());

    app.with(tide::sessions::SessionMiddleware::new(
        tide::sessions::MemoryStore::new(),
        std::env::var("TIDE_SECRET")
            .expect(
                "Please provide a TIDE_SECRET value of at \                      least 32 bytes in order to run this example",
            )
            .as_bytes(),
    ));

    app.with(tide::utils::Before(
        |mut request: tide::Request<()>| async move {
            let session = request.session_mut();
            let visits: usize = session.get("visits").unwrap_or_default();
            session.insert("visits", visits + 1).unwrap();
            request
        },
    ));

    app.at("/").get(|req: tide::Request<()>| async move {
        let visits: usize = req.session().get("visits").unwrap();
        Ok(format!("you have visited this website {} times", visits))
    });

    app.at("/reset")
        .get(|mut req: tide::Request<()>| async move {
            req.session_mut().destroy();
            Ok(tide::Redirect::new("/"))
        });

    app.listen("127.0.0.1:8080").await?;

    Ok(())
}

Summary

  • Minimalistic approach. Minimalistic approach.
  • Uses async-stdruntime.async-std runtime.
  • Simple handler functions.
    Simple handler functions.
  • Playground of async features.

Poem

A program is like a poem, you cannot write a poem without writing it. — Dijkstra

Poem’s readme greets you with these words. Poem claims to be a full-featured and easy-to-use web framework. Bold claim, but Poem seems to have delivered. At first glance, its usage is very similar to Axum, the only difference is that you need to mark the handler function with the corresponding macro. It is also built on Tokio and Hyper and is fully compatible with Tower middleware while still exposing its own middleware features.

Poem’s middleware features are also very simple and easy to use. You can implement the trait directly for all or specific Endpoint(Poem’s way of expressing everything that can handle HTTP requests), or you can just write an acceptEndpoint asynchronous function scope that accepts as a scope. This is a breath of fresh air after dealing with and sometimes struggling with towers and service features for so long.

Not only is Poem compatible with many features in the wider ecosystem, but it’s also packed with features itself, including full support for OpenAPI and Swagger documentation. And it’s not limited to HTTP-based web services, it can also be used with Tonic-based gRPC services and even Lambda functions without switching frameworks. Add support for OpenTelemetry, Redis, Prometheus, and more, and you can check all the boxes for a modern web framework for enterprise-grade applications.

Poem is still in version 0.x, but if it keeps up the momentum and delivers a solid 1.0, this is a framework worth keeping an eye on!

Poem Example

Abbreviated version of websocket chat from the examples repository:

#[handler]
fn ws(
    Path(name): Path<String>,
    ws: WebSocket,
    sender: Data<&tokio::sync::broadcast::Sender<String>>,
) -> impl IntoResponse {
    let sender = sender.clone();
    let mut receiver = sender.subscribe();
    ws.on_upgrade(move |socket| async move {
        let (mut sink, mut stream) = socket.split();

        tokio::spawn(async move {
            while let Some(Ok(msg)) = stream.next().await {
                if let Message::Text(text) = msg {
                    if sender.send(format!("{name}: {text}")).is_err() {
                        break;
                    }
                }
            }
        });

        tokio::spawn(async move {
            while let Ok(msg) = receiver.recv().await {
                if sink.send(Message::Text(msg)).await.is_err() {
                    break;
                }
            }
        });
    })
}

#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
    let app = Route::new().at("/", get(index)).at(
        "/ws/:name",
        get(ws.data(tokio::sync::broadcast::channel::<String>(32).0)),
    );

    Server::new(TcpListener::bind("127.0.0.1:3000"))
        .run(app)
        .await
}

Poem summary

  • Huge feature set.
  • Compatible with Tokio ecosystem.
  • Easy to use.
  • Available for gRPC and Lambda.

Need attention

Pavex

Initially I said that all Rust web frameworks look very similar at first glance. They differ in nuance and sometimes do it better than others.

Pavex is the exception to this rule. Currently, Pavex is implemented by none other than Luca Palmieri, author of the popular book Zero to Production. It can be said without a doubt that Luca knows what he is doing and all his ideas and experience have been incorporated into Pavex.

Pavex is significantly different in that it considers itself a dedicated compiler for building Rust APIs. It requires a high-level description of what the application should do, and the compiler generates a standalone API Server SDK package that can be configured and started.

Pavex is still in its early stages, but it’s definitely a project worth keeping an eye on. Check out Luca’s blog post for more information.

Summarize

As you can see, the world of Rust web frameworks is very diverse. There is no one-size-fits-all solution, you need to choose the framework that best suits your needs. If you are just starting out, I recommend using Actix or Axum as they are the most beginner-friendly frameworks and have great documentation. Personally, I’m interested in what Pavex will bring to the table, and to be honest, after being a long-time user of Axum, I really want to check out Poem.

Leave a Reply

Your email address will not be published. Required fields are marked *