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.xlargefor the server and ac7i.2xlargefor 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:
- 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 torelease_default()withlog_level: LogLevel::Off. - 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=0pins it to one physical core (0,1for the two-core test). -
--memory-swapequal to--memoryturns swap off, so going over 1 GB gets the server killed instead of slowly swapping. -
--network hostskips 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 /todosreturns 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.
