Last week I ran the same app in Go, Rust, Java, and Python on an 8-core server to see how many users it could handle. As usual, a bunch of you said you could do better.
So I open-sourced the whole benchmark and asked for submissions in whatever language and framework you wanted. I got way more than I expected, from Rails and Phoenix apps to hand-written HTTP servers and Postgres drivers. I ran all of them on the same 8-core box.
There were so many that I'm splitting the results in two. This issue covers the 14 entries that still look like code you'd see in production with a real framework, a real driver, tuned. The ones that replaced most of the stack get their own issue, which is coming in a few weeks.
TL;DR
My best original server (Go) held 24,000 users. Seven submissions beat it, and the top three held 43,000 to 46,000.
The biggest wins came from the database side: selecting the page first, fewer round trips, and letting Postgres build the JSON.
Removing the framework was never the whole story. The same developer's Rails app held 10,000 and their bare Rack app held 37,000, but the queries changed too.
Two of the top entries passed my rules while a write endpoint was failing underneath. Read the fine print before copying anything.
Setup
Everyone implements Conduit, a Medium clone with sign up, login, articles, follows, favorites, and comments. 19 API endpoints, and every submission has to pass the same 197-check test suite.
The database is seeded with 100,000 users, 500,000 articles, 5 million favorites, 2.5 million comments, and 2 million follows. Loading a feed requires finding articles, their authors, tags, and whether the viewer has favorited each one.
The VM is from Synteq: 8 dedicated vCPUs and 16 GB of RAM, with Postgres running on the same machine. There is no separate database server. If your app burns more CPU, there's less left for Postgres, and you'll see that split in every result below. Synteq sponsors these experiments and gives you $20 in free credits at synteq.com/arjay if you want to run this yourself.
What submitters are allowed to change: the language, the framework, the queries, the driver, the process layout, and whether Nginx stays in front.
What had to stay fixed:
The schema and indexes. No DDL.
Responses have to match the spec byte for byte.
No caching. No Redis, response cache, or in-memory copy of the data. Every request reads Postgres. Connection pools and prepared statements are fine.
Every login checks a bcrypt hash at cost 10. Every authenticated request verifies its token.
Writes commit before the server says they succeeded, and that time counts toward latency.
Load comes from a custom Go load generator on separate machines. Virtual users browse like real readers: load a page, pause about 7 seconds, open an article, sometimes favorite something or leave a comment. About 5% of requests are writes. It works out to roughly a quarter of a request per second per user, so 20,000 users is about 5,000 requests per second.
To pass, a server has to hold its load for 5 straight minutes with p95 under 500 ms, p99 under 1 second, and under 1% errors. The score is the most users that pass.
Reference points, re-run on this exact box: my Go server held 24,000, Rust 22,000, Java 21,000, and Python 10,000. Keep in mind the submissions changed queries, libraries, and configuration along with the code, so this is not a language benchmark.
Results

Submission | Users | req/s | p95 / p99 ms | CPU: app / Postgres |
|---|---|---|---|---|
46,000 | 11,572 | 423 / 519 | 38% / 58% | |
44,000 | 10,948 | 496 / 664 | 45% / 49% | |
43,000 | 11,264 | 98 / 218 | 28% / 61% | |
37,000 | 9,597 | 343 / 637 | 38% / 41% | |
33,000 | 8,616 | 432 / 783 | 37% / 49% | |
32,000 | 8,376 | 234 / 400 | 40% / 38% | |
26,000 | 6,854 | 71 / 375 | 31% / 44% | |
Swift, Hummingbird (Nginx) | 20,000 | 5,077 | 339 / 400 | 48% / 40% + Nginx 10% |
20,000 | 5,193 | 351 / 563 | 12% / 63% | |
19,000 | 4,985 | 191 / 387 | 50% / 30% | |
18,000 | 4,591 | 296 / 736 | 18% / 33% | |
Scheme, CHICKEN + Spiffy (Nginx) | 11,000 | 2,904 | 75 / 172 | 49% / 32% + Nginx 7% |
10,000 | 2,582 | 349 / 650 | 59% / 30% | |
8,300 | 2,108 | 447 / 720 | 19% / 78% |
CPU is the share of the whole 8-core box during the scored 5 minutes. Scores are good to about 5%.
C# with EF Core: 8,300 users
ASP.NET Core with minimal APIs and Entity Framework Core, the ORM. You write LINQ against C# objects and EF Core turns it into SQL, so the developer gets most of the database plumbing for free.
8,300 sounds low next to my Go server, but look at where the CPU went: the app used 19% of the box and Postgres used 78%. C# isn't struggling to run code. The machine is busy doing all the database work this implementation asked for.
If I were optimizing this one I'd start with the generated queries. Faster application code doesn't help when Postgres has all your compute.
Rails: 10,000 users
Still recognizably Rails: Active Record, Rails password handling, Falcon as the HTTP server, YJIT on, multiple workers to use every core. The developer pulled a couple of middleware pieces (request logging, request IDs) and turned off compression. I'd call it tuned Rails.
The CPU split is the mirror image of the EF Core entry: Ruby used 59% of the box and Postgres 30%.
Hold that thought, because the same developer also submitted a Ruby app without Rails. It's further down.
Phoenix: 19,000 users
Elixir on the Erlang VM, with Phoenix for routing, Ecto for queries, and Bandit as the HTTP server. Controllers, contexts, schemas, and changesets all stayed in place.
It held 19,000. At 20,000 the database connection pool started dropping requests that had waited too long, and the error rate jumped to about 26%. A longer queue would have cut the errors, but those requests still have to finish inside the latency limits. The pool's waiting policy was the limit here, not the language.
Rust with Axum: 43,000 users
Rust has been winning these challenges, but this entry didn't write its own server. Axum handles routing, Tokio Postgres talks to the database, Serde does the JSON.
The interesting part is the queries. For an article list it selects the page of article IDs first, then computes tags, favorite counts, and viewer-specific flags for just those rows. If you're returning 10 articles, you only do the expensive work for 10. It also folds work into fewer SQL statements and keeps read connections separate from write connections, so a read never sits behind a write waiting to commit.
Now the catch, which almost made me throw it out. Article creation was failing about 14% of the time. That endpoint had its own transaction pool with only two connections, and requests timed out waiting for one. Because publishing is a tiny share of this browsing workload, that was only 0.06% of all requests, which passes my rules as written. Browsing looks great while publishing is broken. Don't ship that.
Kotlin with Ktor: 44,000 users
Ktor on the JVM, built around coroutines, with Kotlin serialization for JSON. The database work goes through the Vert.x Postgres client, which supports pipelining: it can send the next query without waiting for the previous one to come back. That doesn't make one connection run queries in parallel. It keeps the connection fed and cuts the time spent waiting between exchanges.
One thing I'd fix before production: it inserts new tags in a separate step before creating the article. If article creation fails, the tags stay behind, and the spec asked for one transaction.
Swift, D, and Scheme
Swift with Hummingbird held 20,000 behind Nginx, with PostgresNIO for the database and custom JSON encoding and token handling.
D with vibe.d held 26,000. It keeps the vibe.d server with an event loop and a database pool per thread, and writes JSON straight into a buffer.
CHICKEN Scheme with Spiffy held 11,000, and keeping Nginx was the smart move. CHICKEN's scheduler scans the file descriptors its lightweight threads are waiting on, so tens of thousands of mostly idle connections get expensive. Nginx holds the client connections and talks to the app over a small set of upstream connections. The proxy is doing real work here. The contributor also swapped the JSON writer after profiling showed serialization dominating app CPU.
Bun: 18,000 users
TypeScript on Bun, using Bun's own HTTP server and SQL client directly, with no framework in between. The launch script starts a single Bun process, so there's no worker per core. I have a JavaScript runtimes issue coming where Bun gets a fairer fight.
V: 20,000 users
V's web framework for routes and response helpers, V's fast HTTP backend underneath, and libpq (the Postgres C client) with a bounded pool. The app used only 12% of the box, so the limit was inside the app, not CPU.
PHP with Symfony on Swoole: 32,000 users
This is where the entries get more aggressive. Symfony's components stay (controllers, dependency injection), but the standard HTTP kernel is replaced with a smaller custom one and several normal components are left out. Swoole keeps PHP workers alive between requests so the app reuses its initialization, and this version batches waiting database reads.
32,000 users with p95 at 234 ms and p99 at 400. One of the cleaner latency profiles in the batch.
Lua with OpenResty: 33,000 users
OpenResty puts LuaJIT inside Nginx's workers, so Nginx runs the API instead of proxying to a separate app server.
Two details worth stealing even if you never write Lua: the driver wasn't reusing prepared statements, so the contributor prepared them explicitly on each connection, and bcrypt moved into a worker thread pool so a password check doesn't stall an event loop that's serving other connections.
Ruby without Rails: 37,000 users
Same developer as the Rails entry. This one is a bare Rack app on Falcon: a handler returns status, headers, and body, with no Rails and no Active Record.
37,000 versus 10,000. But several things changed at once, so this isn't a "remove Rails" result:
Postgres builds most of the finished JSON. Instead of decoding rows into Ruby objects and serializing them back out, the app forwards the response body.
Reads are pipelined, and compatible requests are grouped into one database statement using a 10 ms collection window. These are fresh results for requests waiting right now, with nothing cached for the next group. It saves repeated work at the cost of a little waiting and much more specialized SQL.
Building JSON in Postgres still costs CPU on the same machine. And the slow tail was on writes: the overall p99 passed, but the individual write endpoints had a p99 around 1.6 seconds.
Python without a framework: 46,000 users
uvloop for the event loop, httptools to parse HTTP, asyncpg for Postgres, orjson where JSON needs encoding, and a custom HTTP layer connecting them. Six processes on the 8 vCPUs.
On the hot read paths Postgres builds the response JSON and asyncpg passes it straight through, so Python never creates an object per row. The SQL is also rewritten to cut round trips and to compute article data only after selecting the page.
46,000 users is 4.6 times my original Python server. But the framework, queries, encoding, and process layout all changed. You won't get this by swapping in a different event loop.
What you can take from this
Start with what every request asks the database to do. The EF Core entry spent most of the box on Postgres. The Axum entry selected the page before computing the extras. Several entries cut the number of exchanges between app and database. You can do all of that without leaving your framework.
Then look at the path from the database to the response. How many objects do you create? How many times do you transform the same data? The Ruby and Python entries cut that work by letting Postgres build the JSON, at the cost of SQL that's tied tightly to the API.
Check where requests wait. Phoenix showed the connection pool rejecting work. Axum showed a publishing endpoint failing inside an otherwise passing result. A better average doesn't help when your writes are timing out.
Next issue: the submissions that replaced most of the networking and database stack. Some of those numbers are hard to believe. If you've been sitting on one, there's still a little time to get it in.
Full code and leaderboard: benchmarks.arjaythedev.com. Video: youtu.be/RtkbcX11iVI.
Thank you to everyone who submitted.
Have a good week!
Arjay.
P.S. If you want to get better at spotting these bottlenecks before they hit production, that's what Devmaxx is for. Daily system design questions with short lessons, weekly leagues, and my private Discord. Try it free for 7 days at devmaxx.dev.