TL;DR: I wrote the same Todo API in axum, actix-web and Rocket, then load tested each one in Docker with 1 or 2 CPU cores and 1 GB of RAM. On one core, actix served about 67k requests per second, axum 47k and Rocket 34k. Each server idled at about 1 MB of RAM. I then re-ran it on two AWS spot instances in Mumbai, a c7i.xlarge for the server and a c7i.2xlarge for the load generator, and got the same ranking over a real network. Before any of that, review found a bug in my axum version that let one bad request take the whole server down, and two settings that would have made the benchmark unfair. Code and full results: github.com/2u841r/actix-vs-axum-vs-rocket.

Why

Every "Rust web framework benchmark" I find runs on a 64-core machine and reports millions of requests per second. That tells me nothing. The servers I actually pay for have 1 or 2 vCPUs and 1 GB of RAM. I wanted to know how much each framework can handle on that.

The API

Five routes, identical in all three projects:

Method Path Does
GET /health returns OK
GET /todos lists all todos
GET /todos/{id} one todo, or 404
POST /todos creates a todo
DELETE /todos/{id} deletes, 204

State is a Mutex<HashMap<u64, Todo>> plus an AtomicU64 for ids. Nothing clever, and the same in all three, so I compare the frameworks and not my data structures.

The axum version is the shortest of the three:

let app = Router::new()
    .route("/health", get(|| async { "OK" }))
    .route("/todos", get(list_todos).post(create_todo))
    .route("/todos/{id}", get(get_todo).delete(delete_todo))
    .with_state(state);

The bug that killed the server

My first axum get_todo looked like this:

let todo = state.todos.lock().unwrap().get(&id).cloned().unwrap();

It compiles and passes clippy pedantic, and it works for every id that exists. Ask for one that doesn't and the last unwrap() panics.

The panic alone wouldn't be fatal, since axum only drops that connection. The problem is when it panics. The MutexGuard from lock() is a temporary, and temporaries live until the end of the statement. So the panic happens while the lock is still held, which poisons the mutex. From then on, every lock().unwrap() in every handler panics too.

One GET /todos/999 and the server is effectively dead.

The fix is to return a 404 instead of panicking:

async fn get_todo(
    State(state): State<Arc<AppState>>,
    Path(id): Path<u64>,
) -> Result<Json<Todo>, StatusCode> {
    state.todos.lock().unwrap().get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}

I first tried returning Option<Json<Todo>>, which Rocket handles as a 404. axum 0.8 no longer implements IntoResponse for Option, so it doesn't compile there. Result with a status code works.

Making the comparison fair

Two settings would have skewed the results before I ran anything:

  1. Rocket logged every request. I had built its config from Config::debug_default(), which logs at the normal level. Printing a line per request at 30k req/s costs a lot, and the other two log nothing. I switched to release_default() with log_level: LogLevel::Off.
  2. actix had a hardcoded .workers(8). Tokio and Rocket size their thread pools from the CPU count. With 1 CPU, actix would have run 8 workers against everyone else's 1. I removed it.

All three frameworks call std::thread::available_parallelism(), which respects both the container's cgroup limit and its CPU pinning. Inside a container pinned to one core, nproc prints 1 and each framework starts one worker. That is what makes the comparison equal.

The setup

One shared multi-stage Dockerfile builds all three:

FROM rust:1-bookworm AS build
ARG APP
WORKDIR /src
COPY ${APP}/ ./
RUN cargo build --release && cp target/release/${APP} /app

FROM debian:bookworm-slim
COPY --from=build /app /app
ENTRYPOINT ["/app"]

Each server runs like this:

docker run --rm --network host \
  --cpuset-cpus=0 --memory=1g --memory-swap=1g \
  axum-api
  • --cpuset-cpus=0 pins it to one physical core (0,1 for the two-core test).
  • --memory-swap equal to --memory turns swap off, so going over 1 GB gets the server killed instead of slowly swapping.
  • --network host skips docker-proxy, which would otherwise add its own overhead to every request.

My laptop has a Ryzen 5 8645HS with 6 cores and 12 threads. lscpu -e=CPU,CORE shows that CPUs 6 and 7 are the hyperthread siblings of 0 and 1. I left them idle and pinned the load generator, oha, to the remaining cores:

taskset -c 2-5,8-11 oha -z 10s -c 1000 --no-tui http://localhost:3001/todos

If the load generator shares cores with the server, you end up measuring the load generator.

For each framework, bench.sh adds 100 todos, warms up for 3 seconds, then runs GET /todos at 50, 100, 500 and 1000 connections and finally POST /todos, 10 seconds each. After every run it reads memory.peak from the container's cgroup.

Results

1 CPU, 1 GB RAM

actix-web axum Rocket
GET req/s, 100 connections 67,205 47,328 33,792
GET req/s, 1000 connections 58,279 44,788 31,459
GET p99, 1000 connections 19 ms 156 ms 172 ms
POST req/s, 100 connections 123,745 113,489 70,511
Idle RAM 1.4 MB 0.9 MB 1.0 MB
Peak RAM, 1000 connections 28 MB 37 MB 38 MB

2 CPUs, 1 GB RAM

actix-web axum Rocket
GET req/s, 100 connections 128,872 80,511 63,736
GET req/s, 1000 connections 109,103 65,114 57,171
GET p99, 1000 connections 11 ms 56 ms 77 ms
POST req/s, 100 connections 236,629 217,888 133,975

No errors and no out-of-memory kills in any run. The full table, including p50 latency for every run, is in the repo README.

What the numbers say

actix wins on throughput. It did about 1.4x what axum did and about 2x Rocket. It also scaled best from one core to two.

The latency story is more interesting. At 1000 connections on one core, axum's median was 5 ms against actix's 17 ms, but axum's p99 was 156 ms against actix's 19 ms. axum and Rocket serve most requests quickly and make a few wait a long time. actix serves everyone at about the same speed. If you care about the slowest 1% of your users, that matters more than the req/s number.

RAM doesn't matter at this scale. All three idle at about 1 MB and stay under 50 MB holding 1000 open connections. A 1 GB server runs out of CPU long before it runs out of memory. The POST runs did reach 180 to 360 MB, but that was the HashMap holding over a million todos after 10 seconds, not framework overhead.

Rocket is "slow" only next to the other two. 31k requests per second on a single core is about 2.7 billion requests a day. No app I run gets anywhere near that.

Round two: AWS, over a real network

The laptop run had one obvious hole: the load generator and the server shared a machine and talked over localhost. So I ran it again on AWS in ap-south-1 (Mumbai), with two spot instances:

Role Instance vCPU RAM
Server c7i.xlarge 4 (2 physical cores) 8 GB
Load generator c7i.2xlarge 8 16 GB

Both ran Amazon Linux 2023, in the same AZ and a cluster placement group, talking over private IPs. I didn't pick c7i.large for the server, because its 2 vCPUs are two hyperthreads of one core, and the 2 CPU test would have meant something different from the laptop. On the c7i.xlarge, vCPUs 0/2 and 1/3 are hyperthread pairs, so the server got 0 or 0,1, with 2 and 3 left idle. The container still had the same 1 GB cap.

There was no SSH. Both boxes were driven with SSM Run Command, and the security group only let the load generator reach ports 3001 to 3003. The whole thing, Docker builds included, took about 20 minutes and cost well under a dollar at spot prices.

1 CPU, 1 GB RAM, on AWS

actix-web axum Rocket
GET req/s, 100 connections 66,252 47,784 40,390
GET req/s, 1000 connections 63,885 42,090 36,304
GET p99, 1000 connections 16 ms 142 ms 165 ms
POST req/s, 100 connections 171,394 150,572 73,327

2 CPUs, 1 GB RAM, on AWS

actix-web axum Rocket
GET req/s, 100 connections 111,959 79,327 61,285
GET req/s, 1000 connections 107,731 43,867 58,774
GET p99, 1000 connections 10 ms 65 ms 75 ms
POST req/s, 100 connections 286,742 228,708 113,680

On one core, the cloud matched the laptop almost exactly: actix 66k vs 67k, axum 48k vs 47k. Rocket did a bit better on AWS, 40k vs 34k. POST was faster across the board, likely because the load generator had a whole machine to itself.

The surprise was axum on 2 CPUs at high connection counts. In the main run it fell to about 44k req/s at 1000 connections, below Rocket. I didn't trust that, so I ran it twice more while the instances were still up:

axum, 2 CPUs c=100 c=500 c=1000
Run 1 79,327 44,231 43,867
Run 2 80,642 76,913 43,217
Run 3 80,250 81,166 76,492

It lands at either about 44k or about 78k, never in between. actix and Rocket stayed within a few percent in every run. I haven't found the cause yet, but it's a good reminder that one run can lie, and that a stable framework is worth something on its own.

Caveats

  • The laptop numbers are a single run. CPU boost and heat move numbers by 5 to 10%, so don't read anything into small gaps.
  • The laptop run is over localhost. The AWS run below fixes that.
  • GET /todos returns 100 todos, about 8 KB of JSON. That measures cloning and serde as much as routing.
  • The AWS run is one region and one instance family. Graviton (Arm) or AMD instances may rank differently.
  • A real app talks to a database. As I found in latency is geography, the database round trip will cost far more than any difference between these three frameworks.

So which one?

On raw numbers, actix-web, both on my laptop and on AWS. In practice, any of them will handle far more than a 1 CPU server will ever be asked to do. I would pick axum for its ecosystem (tower middleware, tokio everywhere) and how little code it takes, and reach for actix when tail latency under heavy load really matters.

Whichever you pick, don't unwrap() while holding a lock.

The code, the Dockerfile and the benchmark script are on GitHub: 2u841r/actix-vs-axum-vs-rocket. Run ./bench.sh on your own machine and see whether your numbers match mine.