Benchmark Round 2: Rust, Go and the Rust Frameworks, Profiled
Go and seven Rust apps (axum, actix-web, Rocket, Loco, Cot and Rustango on axum and actix) under the same rules, with only the app under test running, and the CPU, syscall and wire profiles that show where each one spends its time.
This is round 2 of the fair benchmark. Round 2 covers the compiled apps only: Go and seven Rust apps. It adds three Rust frameworks (Rocket, Loco, Cot) and CPU, syscall and wire profiles that show where each app spends its time.
Measured on 14 October 2026 on an Apple M3 Pro (Docker VM with 12 CPUs and 16 GB).
- 441/s Go garbage collections on the cached index
- 3 vs 9 Postgres round trips per detail page: Go vs sqlx with the acquire ping
- +45% Rustango on actix, index page, with one pool slice per worker
- 6.3–22.8 MiB memory under load, all eight apps
- 0 errors or non-200 responses in 144 runs
The setup
The same blog as round 1: 10 authors, 30 tags, 1 000 posts, 2 600 post-tag links and 10 000 comments. Six routes: the index (latest 20 posts), a post detail page with all its comments, a tag page, the index and detail page from a Redis page cache, and /compute (sum of primes below 20 000, no database).
Eight apps:
- Go: stdlib
net/http, pgx v5, go-redis - axum + sqlx and actix + sqlx: raw sqlx with Go's SQL text, Tera templates
- Rocket:
rocket_db_pools(sqlx) androcket_dyn_templates(Tera) - Rustango on axum and on actix-web: the Rustango ORM and its page cache
- Loco: SeaORM, Tera
- Cot: Cot's ORM, askama templates
The rules:
- The app gets 4 CPUs (pinned to cores 0–3) and 2 GB RAM, with 4 workers. PostgreSQL 16 and Redis 7 run on cores 4–7, the load generator oha on cores 8–11.
- Only the app under test runs; the other apps are stopped. Between apps, the number of Postgres connections goes back to its baseline.
- Pools: 16 connections max, 4 min.
- Before any load, every page of every app is compared with Go's HTML. A mismatch stops the run.
- No compression. 5 s warm-up, then three 20 s runs per endpoint at 50 connections; the median counts.
The harness
Same harness as round 1. Round 2 uses PostgreSQL and Redis only (no MySQL).
Versions
- Go 1.25.14, pgx v5.7.2, go-redis v9.7.0
- rustc 1.99.0; axum 0.8.9, actix-web 4.16.0, tokio 1.53, sqlx 0.8.6, Tera 1.20.1
- Rocket 0.5.1 (sqlx 0.7.4, hyper 0.14)
- Loco 1.2.0 (SeaORM 2.0.4, sqlx 0.9.0)
- Cot 0.7.0 (sqlx 0.9.0, askama 0.16.1)
- Rustango with no acquire ping, a page cache that stores raw bytes and one pooled connection per request (finding 5)
Settings that could not be equalized
| Setting | Apps | Why |
|---|---|---|
Pool pings Postgres on every checkout (sqlx test_before_acquire) | Rocket, Loco, Cot | No setting to turn it off. It is off in axum + sqlx, actix + sqlx and Rustango. |
| 10 pool connections instead of 16 | Cot | Cot has no pool settings. |
| One pool slice per worker (4 of the 16 connections) and one Redis connection per worker | actix + sqlx, Rustango · actix | A shared pool is slower on actix-web (see finding 6). |
| sqlx 0.7.4 and hyper 0.14 | Rocket | Pinned by Rocket 0.5.1. |
| 5, 5 and 7 queries per index, detail and tag page instead of 3, 3 and 4 | Cot | Its query API cannot express Go's queries (see finding 9). |
| askama instead of Tera | Cot | Cot's template engine. |
Results
Requests per second, higher is better.
Uncached pages
Same HTML, 50 connections, PostgreSQL on every request
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Median of three 20 s runs at 50 connections. Only the app under test was running. 0 non-200 responses, 0 errors.
Cached pages
Served from a Redis page cache, no SQL
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Median of three 20 s runs at 50 connections. Only the app under test was running. 0 non-200 responses, 0 errors.
Compute
Primes below 20 000, no database
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Median of three 20 s runs at 50 connections. Only the app under test was running. 0 non-200 responses, 0 errors.
Latency, p50 / p99 in ms:
| App | Index | Detail | Tag | Index, cached | Detail, cached | Compute |
|---|---|---|---|---|---|---|
| Go | 5.50 / 9.96 | 1.91 / 5.02 | 7.61 / 11.72 | 0.35 / 2.73 | 0.24 / 2.05 | 2.37 / 10.75 |
| axum + sqlx | 3.49 / 4.71 | 2.14 / 3.60 | 7.42 / 13.89 | 0.24 / 0.53 | 0.22 / 0.51 | 3.35 / 6.07 |
| actix + sqlx | 3.32 / 6.38 | 2.21 / 4.30 | 7.23 / 13.23 | 0.20 / 0.50 | 0.19 / 0.45 | 3.41 / 4.97 |
| Rocket | 3.66 / 5.83 | 1.97 / 3.58 | 7.14 / 11.55 | 0.34 / 0.70 | 0.30 / 0.67 | 3.40 / 6.14 |
| Rustango · axum | 3.28 / 5.65 | 1.83 / 3.55 | 6.90 / 11.18 | 0.27 / 0.59 | 0.25 / 0.52 | 3.38 / 6.09 |
| Rustango · actix | 2.80 / 4.51 | 1.77 / 3.37 | 6.91 / 11.53 | 0.20 / 0.48 | 0.19 / 0.45 | 3.41 / 3.96 |
| Loco | 4.62 / 10.84 | 2.99 / 6.34 | 8.67 / 14.52 | 0.30 / 0.79 | 0.28 / 0.62 | 3.48 / 6.90 |
| Cot | 23.35 / 50.36 | 5.28 / 8.02 | 15.57 / 27.06 | 0.25 / 0.50 | 0.23 / 0.52 | 3.27 / 5.85 |
Memory
Memory under load
Resident memory of the app container on the uncached index
MiB · lower is better- Go
- Rust
- Rustango
- Loco
- Cot
docker stats, the sample with the highest CPU out of six taken during the first index run.
Go and Cot use the most. Cot's index loads all 1 000 posts with their bodies on every request (finding 9).
Findings
Each finding says what was measured, why it happens and how that was shown. Profiles are single 25 s runs on the same harness, one app at a time, so their req/s differ a little from the fair run above. Where a cause is not proven, it says so.
1. Compute: Go is 8–17% faster
What. Go serves 16 580 req/s on /compute; the Rust apps serve 14 184–15 330.
Why. Both compiled loops have 8 instructions, a 64-bit sdiv and one divide-by-zero check. The difference is the order of the instructions that LLVM picks. Which micro-architectural detail of the CPU makes that order slower is unproven: the Docker VM has no hardware counters.
How proven. The loop alone, on one core, outside HTTP:
Prime loop, one call
Single core, no HTTP
microseconds · lower is better- Go
- Rust
Rust with Go's exact instruction order runs in Go's time. The rest of each request is cheaper in Rust: 8 µs against Go's 14 µs.
Latency. Go has the lower p50 but the higher p99. Mean latency is 50 / req/s for every app, so the averages follow throughput; the difference is the spread. Go serves requests out of order: p10 0.56 ms, p99 10.75 ms. The Rust apps serve close to first-in, first-out: axum + sqlx p10 1.85 ms, p99 6.07 ms. Why Go's p99 sits near 10 ms is unproven.
2. Cached pages: Go's garbage collector
What. On the cached index Go serves 91 839 req/s; axum + sqlx serves 198 669.
Why. Go runs 441 garbage collections per second on this route, and the stop-the-world pauses add up to about 10% of the time. The rest is CPU per request: 27.2 µs in Go against 14.6 µs in axum + sqlx.
How proven. Raising GOGC removes most of the gap:
Go, cached index, GC tuned
Before: default GOGC=100. Profiling runs.
Where the remaining CPU goes, per request on the cached index:
| Go | axum + sqlx | |
|---|---|---|
| App CPU | 27.2 µs | 14.6 µs |
| Kernel write path | 10.6 µs | 6.9 µs |
| Syscalls | 10.7 (6.6 read, 2.9 write) | 4.8 (2.0 recvfrom, 1.4 writev) |
| Redis CPU | 6.8 µs | 2.5 µs |
| Redis traffic | go-redis: one GET per request | one connection, GETs from many requests batched in one pipeline |
Go: CPU per cached request
27.2 µs in total
- 39%Kernel write path, 10.6 µs
- 61%Everything else, 16.6 µs
axum + sqlx: CPU per cached request
14.6 µs in total
- 47%Kernel write path, 6.9 µs
- 53%Everything else, 7.7 µs
3. Detail page: Postgres round trips
What. Detail page: Go 22 607, axum + sqlx 22 842, Rocket 24 561, Rustango · axum 25 788 req/s. All of them send the same three queries.
Why. The number of round trips to Postgres differs. pgx sends one per query. sqlx adds one when a connection is taken from the pool (the acquire ping) and one when it goes back. axum + sqlx takes a connection per query, so it pays per query; Rocket and Rustango take one connection per page, so they pay once. Each extra round trip costs about 10 µs of Postgres CPU.
How proven. Postgres wire capture during load, counting ReadyForQuery messages per page:
Postgres round trips per detail page
Wire capture, same three queries; ping = sqlx acquire ping
round trips · lower is better- Go
- Rustango
- Rust
The index page shows the same counts.
axum + sqlx: acquire ping turned off
Profiling runs
Three fewer round trips save 29–32 µs of Postgres CPU per page: about 10 µs each.
Go has the fewest round trips but is not the fastest here: its garbage collector stops it about 9% of the time on this route.
4. Index page: GC and template rendering
What. Index page: Go 8 741 req/s, axum + sqlx 14 140.
Why. Two measured causes. Go's garbage collector again (pauses add up to about 8% of the time on this route), and template rendering: Go's html/template uses reflection and costs 167 µs per request; serde + Tera in Rust cost 72 µs.
How proven.
Go, uncached index, GC tuned
Before: default GOGC=100. Profiling runs.
Template rendering, index page
CPU per request, from the CPU profile
microseconds · lower is better- Go
- Rust
Where axum + sqlx spends CPU on the index, from its profile (the parts overlap: allocations happen inside sqlx and Tera):
- sqlx: about 112 µs per request, of which about 25 µs is the ping when a connection goes back to the pool
- glibc
malloc: about 56 µs - kernel
sendto: 38 µs
5. Rustango: what the fixes changed
What. Profiles of Rustango before these fixes showed four costs. Three are fixed:
| Cost before | CPU share | Fix |
|---|---|---|
| A pool checkout for every query | 19–35% | One pooled connection per request: detail +26% |
| Ping to Postgres on every checkout | 8–16% | Ping off by default |
CachePageLayer stores pages as JSON + base64 | ~12–20% per cache hit | Pages stored as raw bytes |
| Querysets rebuilt and compiled on every request | ~2% | Not fixed yet |
Rustango · axum, before → after the fixes
Profiling runs, one app at a time
In the fair run, Rustango on actix-web and on axum are the two fastest apps on all three uncached pages.
Open. On the cached index Rustango · axum serves 174 501 req/s against axum + sqlx's 198 669. This gap has not been profiled yet.
6. actix-web: a shared pool is slow
What. In round 1 the actix-web apps trailed axum on uncached pages and used under 300% of their 400% CPU budget.
Why. With one shared pool, every query wakes a thread of another runtime. Giving each actix worker its own pool slice (4 of the 16 connections) and its own Redis connection fixes it. The mechanism, that a socket is driven by the runtime that opened it, is an inference; the effect is measured.
How proven. Same apps, shared pool against per-worker slices:
Shared pool → one pool slice per worker
Profiling runs
| Context switches per request | Shared pool | Pool per worker |
|---|---|---|
| actix + sqlx, index | 4.04 | 1.45 |
| actix + sqlx, detail | 3.57 | 1.64 |
| Rustango · actix, index | 2.70 | 0.81 |
| Rustango · actix, detail | 2.34 | 1.03 |
About 2.5× fewer context switches per request. More workers do not help: 8 workers with a shared pool reach 11 904 req/s on the actix + sqlx index (4 workers, shared: 11 094).
7. Rocket: strong on database pages, slowest cached
What. Rocket: detail 24 561 req/s, ahead of Go and the raw sqlx apps; cached index 138 224, the slowest Rust result.
Why. On database pages Rocket takes one connection per page and makes 5 round trips (finding 3). On cached pages it spends 24.1 µs of CPU per request against axum + sqlx's 14.6 µs, with 8.1 syscalls per request against 4.8. The profile shows an extra writev (2.24 per request against 1.36) and contention in malloc; futex calls are 2.88 per request against 0.91.
How proven. CPU accounting, syscall counts and CPU profiles on the cached index.
8. Loco
What. Loco: index 10 290, detail 15 635, cached index 153 243 req/s.
Why. SeaORM sends the same number of queries as Go. On cached pages Loco spends 21.0 µs per request: 2.4 µs more in malloc and 1.2 µs more in middleware than axum + sqlx. On database pages the acquire ping cannot be turned off (finding 3 shows its cost in sqlx).
How proven. CPU accounting and CPU profiles on the cached index. Loco's database pages were not profiled, so how much of that gap the ping explains is unproven.
9. Cot: the query API sets the limit
What. Cot: index 2 018, detail 9 282, tag 3 065 req/s, but cached index 190 245.
Why. Cot's query API has only filter, limit and offset: no ORDER BY, JOIN, IN or COUNT. To show the latest 20 posts, the index loads all 1 000 posts with their bodies. Pages need 5, 5 and 7 queries instead of 3, 3 and 4. Memory under load is 22.6 MiB. Cached pages do not touch the ORM and are fast: 16.4 µs of CPU per request.
How proven. Query counts from the code, memory from the fair run, CPU accounting on the cached index.
10. Memory
See the memory chart above. Six of the Rust apps use 6.3–12.0 MiB under load. Go uses 22.8 MiB. Cot uses 22.6 MiB; its index loads every post on every request (finding 9).
11. Shared costs
Two costs show up in every Rust app and belong to no framework:
- Tera rendering: about 18–20% of CPU on the uncached pages
- glibc
malloc: about 12%
A faster allocator such as mimalloc or jemalloc could cut the second one. This is untested.
Limits
- One host: an Apple M3 Pro. Other CPUs can order the results differently, especially compute (finding 1).
- A read-heavy workload: no writes, no transactions.
- The apps run in the Docker VM on macOS, not on bare-metal Linux. The VM has no hardware performance counters.
Conclusion: why Rustango on actix-web got so much faster
Rustango · actix, index page
Three runs: round 1, a profiling run, and this round's fair run
req/s · higher is betterTwo changes, in two places.
1. Rustango fixes. These help on axum and on actix-web:
- Each column is decoded once, by its type. Before, decoding took 46% of the CPU.
- No ping before each pool checkout: one less Postgres round trip per query.
- One pooled connection per request: a page's queries share one connection, instead of one checkout and one return ping each.
2. The actix-web setup. axum runs one shared, multi-threaded Tokio runtime. actix-web runs one single-threaded runtime per worker. With one pool shared by all workers, a connection's socket stays driven by the runtime that opened it, so when another worker uses that connection, every read and write wakes the other thread. The app then makes 2.5× more context switches and uses about 280% of its 4 CPUs. One pool slice (4 of the 16 connections) and one Redis connection per worker remove these cross-thread hops:
- Rustango · actix: index 12 052 → 17 454, detail 23 032 → 28 693, cached index 190 889 → 237 962 req/s
- actix + sqlx: index 11 094 → 15 108 req/s
- 8 workers with a shared pool did not help: 11 904 req/s on the actix + sqlx index
The speed-up and the drop in context switches are measured. The socket ownership is the best explanation, but it is not proven directly. axum does not have this problem, because all its tasks run on one shared runtime.
In practice: on actix-web, create the database pool inside each worker, not once for all workers.