The same CRUD API, built 7 times
One products table, four scenarios, one shared Postgres, identical container limits, and every framework on its own ecosystem's ORM: GORM, Eloquent, Prisma, Drizzle. Each framework was run head to head against Grit, back to back, three times, and each has a page showing exactly how to reproduce it.
Read the ratios, not the raw numbers
All six pairs below were measured back to back in one sitting on v3.134.0, and even so Grit's single-row read ranged from 4,536 req/s in the Bun pair to 8,509 in the Express pair, from an identical binary, because the machine drifts as write scenarios accumulate in Postgres. Within a pair both sides ran minutes apart under the same conditions, so the ratio survives; lining Bun's 2,717 up against Encore's 663 would imply a shared baseline that does not exist. Every table below is grouped by pair for that reason.
Results
50 concurrent users, 30 seconds per run, 4 CPUs and 2 GB per app container. Every app shares one Postgres on 8 CPUs, deliberately more than any of them gets. Medians of three repetitions, never a best run.
Grit vs
Bun
v1.3 + Drizzle| Scenario | Grit | Bun | Ratio |
|---|---|---|---|
show GET /api/v1/products/:id | 4,536 8.2 ms · neither saturated | 2,717 16.2 ms · own ceiling | 1.67× |
write POST /api/v1/products | 4,959 7.7 ms · neither saturated | 2,224 18.0 ms · own ceiling | 2.23× |
list GET /api/v1/products?page=N&page_size=20 | 615 65.6 ms · DB-bound | 590 70.8 ms · DB-bound | 1.04×within noise |
mixed a weighted blend of the three above | 568 70.9 ms · DB-bound | 621 65.8 ms · DB-bound | 0.91×within noise |
Grit vs
Encore.ts
v1.57 + Drizzle| Scenario | Grit | Encore.ts | Ratio |
|---|---|---|---|
show GET /api/v1/products/:id | 6,646 5.3 ms · neither saturated | 663 71.9 ms · neither saturated | 10.02× |
write POST /api/v1/products | 4,345 8.7 ms · neither saturated | 854 51.9 ms · neither saturated | 5.09× |
list GET /api/v1/products?page=N&page_size=20 | 617 67.0 ms · DB-bound | 249 166.0 ms · neither saturated | 2.48× |
mixed a weighted blend of the three above | 834 50.7 ms · DB-bound | 363 138.0 ms · neither saturated | 2.30× |
Grit vs
Express
v5 + Prisma| Scenario | Grit | Express | Ratio |
|---|---|---|---|
show GET /api/v1/products/:id | 8,509 4.5 ms · neither saturated | 982 55.1 ms · own ceiling | 8.66× |
write POST /api/v1/products | 5,126 7.5 ms · neither saturated | 748 72.8 ms · own ceiling | 6.85× |
list GET /api/v1/products?page=N&page_size=20 | 861 46.8 ms · DB-bound | 280 168.7 ms · own ceiling | 3.08× |
mixed a weighted blend of the three above | 763 51.5 ms · DB-bound | 297 151.5 ms · own ceiling | 2.57× |
Grit vs
Next.js
v15.5 + Prisma| Scenario | Grit | Next.js | Ratio |
|---|---|---|---|
show GET /api/v1/products/:id | 6,822 5.5 ms · neither saturated | 433 103.0 ms · own ceiling | 15.76× |
write POST /api/v1/products | 6,157 6.2 ms · neither saturated | 499 93.0 ms · own ceiling | 12.34× |
list GET /api/v1/products?page=N&page_size=20 | 1,186 35.6 ms · DB-bound | 247 189.7 ms · own ceiling | 4.80× |
mixed a weighted blend of the three above | 1,053 38.5 ms · DB-bound | 271 181.0 ms · own ceiling | 3.89× |
Grit vs
Django
v5.1 + Django ORM| Scenario | Grit | Django | Ratio |
|---|---|---|---|
show GET /api/v1/products/:id | 5,983 6.0 ms · neither saturated | 811 73.4 ms · own ceiling | 7.38× |
write POST /api/v1/products | 5,901 6.2 ms · neither saturated | 913 65.1 ms · own ceiling | 6.46× |
list GET /api/v1/products?page=N&page_size=20 | 1,094 36.9 ms · DB-bound | 379 118.5 ms · own ceiling | 2.89× |
mixed a weighted blend of the three above | 1,163 36.2 ms · DB-bound | 481 77.4 ms · own ceiling | 2.42× |
Grit vs
Laravel
v13 + Eloquent| Scenario | Grit | Laravel | Ratio |
|---|---|---|---|
show GET /api/v1/products/:id | 7,167 5.2 ms · neither saturated | 275 68.8 ms · own ceiling | 26.06× |
write POST /api/v1/products | 4,497 8.0 ms · neither saturated | 318 23.9 ms · own ceiling | 14.14× |
list GET /api/v1/products?page=N&page_size=20 | 1,130 37.6 ms · DB-bound | 220 71.7 ms · own ceiling | 5.14× |
mixed a weighted blend of the three above | 1,180 34.7 ms · DB-bound | 210 78.7 ms · own ceiling | 5.62× |
Where Grit loses
Against Bun, two rows cannot be called. The paginated list and the mixed workload were measured four separate times. Mixed came out 0.91×, 1.21×, 1.19× and 0.91×, on both sides of even. Across seven repetitions Grit ranged from 329 to 643 req/s and Bun from 445 to 694. Any one of those numbers could be published as a result, which is exactly why none of them should be.
The reason is in the CPU column. Postgres sits at 764–825% of its 800% allowance for both sides while Grit's own container idles at 125%. Neither framework is the limit; they are both waiting on the same database, so what moves between runs is database state rather than framework speed. Those two rows are marked within noise in the table above and no winner is claimed.
The insert scenario used to be a real loss, and it is not any more. Bun was a quarter quicker at it. Postgres statement logging showed a single POST sending seven statements where Bun sent one: a transaction around an INSERT that Postgres already makes atomic, a SELECT re-reading the row just written, and an audit row written even for requests with no authenticated user. v3.134.0 fixed all three, inserts went from 2,686 to 4,959 req/s, and the scenario reversed from a 0.81× loss to a 2.23× win.
What the CPU column is for
A throughput number only means something when you know what stopped it. Where a framework's own container saturated, Laravel at 402–420% of its 400% allowance, Django at 398–408%, Next.js at 400–411%, Express at 416–434%, Bun at 399–411%. The figure is that framework's genuine ceiling on this hardware. Encore is the one exception: it never saturated anything, sitting at 142–195% with Postgres at 26–176%, so something else bounded it and its figures are a floor.
The read-heavy rows are different. On list and mixed Grit used about 120–155% while Postgres sat near 900%: the database ran out first, not the framework. Those rows are a floor, and the real gap is wider than the ratio shows. Encore.ts is the mirror image: it never saturated anything, sitting at 155–211% with Postgres at 30–200%, so something else bounded it (most likely the Drizzle / node-postgres path rather than its Rust HTTP layer) and its numbers are a floor for Encore too.
The list endpoint runs a COUNT(*) over the whole table on every request, on every framework. That is a fair comparison (they all do the same work) but at 50 concurrent users it is mostly a measurement of Postgres. For the cleanest read of framework overhead, look at show: one indexed lookup, one JSON encode, nothing else in the way.
Three bugs this found in Grit
The first draft of these numbers was much worse, and the reason was Grit's fault every time. Building a benchmark you intend to publish is an unusually good way to find out your own defaults are wrong.
Connection-pool churn (fixed in v3.131.0)
The scaffold shipped SetMaxIdleConns(10) alongside SetMaxOpenConns(100). Past ten concurrent requests, a connection handed back to a full idle pool is closed, and the next request makes Postgres fork a new backend. Under load that is a connection storm, and it shows up as database CPU rather than as anything you would think to look for in the application.
Single-row reads went from ~810 to ~2,720 req/s on the one-line change. Idle now defaults to Open, tunable via DB_MAX_OPEN_CONNS and DB_MAX_IDLE_CONNS.
REDIS_URL= did not disable Redis (fixed in v3.132.0)
getEnv treated an empty value as unset and returned the default, so the asynq worker started anyway, failed to dial, and retried in a tight loop, burning CPU on reconnects with nothing in the logs but dial errors. Setting it empty now genuinely turns cache, jobs, worker and cron off, and says so once at boot.
No prepared-statement cache (fixed in v3.133.0)
GORM re-sent and Postgres re-planned every query. PrepareStmt is now on by default, so a query that runs a thousand times is planned once per connection. Turn it off with DB_PREPARED_STATEMENTS=false if you run pgbouncer in transaction mode, where server-side prepared statements do not survive between requests.
Re-running the Bun pair afterwards is what put a number on it. Inserts went from 1,568 to 2,686 req/s on the same hardware, which took Grit from 2.09× slower than Bun to 1.23×. Bun still wins that scenario, and the withdrawn figures are gone from this page rather than left standing because they flattered the fix.
How the comparison is kept fair
A benchmark is only worth publishing if the loser was given every reasonable advantage. These rules apply to every pair; the framework-specific decisions are spelled out on each guide page.
- Every framework uses its ecosystem's ORM: GORM, Eloquent, Prisma, Drizzle. Not raw SQL. Hand-written SQL on one side against an ORM on the other measures the ORM, and nobody ships the framework that way.
- Every framework runs in production shape, not its dev server: php-fpm rather than
artisan serve, a cluster worker per CPU for Node, a production build for Next.js. - Identical schemas, down to the index on
deleted_at. A benchmark where one side has an index the other lacks measures the index. - The handlers match: same default page size and cap, same searchable columns, same sortable allow-list, same
{data, meta}envelope, same version bump on update. - No auth on either side. With a token in play, part of what you measure is JWT parsing rather than the request path.
- One app runs at a time, the other stopped rather than idle, and a warm-up run is discarded so nobody pays for the other's cold start.
- The database is truncated and reloaded before every single run, and the row count is verified at 10,000 before the run is allowed to start.
- Medians of three, never the best run, and any run with a single failed request is thrown out rather than published.
Four ways this benchmark lied before it was fixed
All four are worth knowing if you build one of these yourself, because none of them announces itself. You get plausible numbers that are simply wrong. Three hurt whichever framework was being measured; the fourth hurt only Laravel, and it was published for a day before it was caught.
Laravel was measured with its dev dependencies loaded
composer install had run without --no-dev, so laravel/pail and nunomaduro/collision were registered as service providers on every request. A route touching no database at all took 27 ms. With a production-only vendor it takes 7 ms.
Two more, found alongside it: Laravel was opening a fresh Postgres connection on every request while every other framework here held a pool open, and Laravel's default sslmode=prefer made each of those connections attempt an SSL handshake and fall back. Nobody else was paying either cost.
Corrected, Laravel's single-row read went from 113 to 175 req/s and the ratio on that scenario fell from 41.8× to 24.8×. The published figures now come from the corrected run. A benchmark that flatters you because the other side was misconfigured is not a benchmark, and this one said out loud that artisan optimize had been run when the harness never ran it.
The write scenario poisoned the read ones
Inserts persist. So list was running COUNT(*) against a table that grew for the whole session: 345,680 rows on the Grit side against 30,255 on Laravel's, because Grit writes faster and therefore polluted its own table harder, then paid for it on every read. Grit's list measured 20 req/s that way. With a verified reset before every run it measures in the hundreds.
k6 must run inside the container network
On Docker Desktop for Windows a published port goes through a userland proxy, and the proxy hits its ceiling before either framework does. Measured through it: 432 req/s with a 3.94 ms floor. Container-to-container, same test: 740 req/s at 1.11 ms. Everything on this page is container-to-container.
A run that fails completely still prints a number
When an app was not listening, k6 recorded thousands of instant connection refusals and summarised them as a perfectly plausible request rate with a sub-millisecond median. Nothing in the summary line says “all of these were errors.” The harness now health-checks before every measurement and refuses to publish any run whose failure rate is above zero.
Reproduce it
The whole harness is in benchmarks/ in the Grit repository, compose file, seed data, k6 script and every bench app. One pair per invocation, by design: running two at once means they share a Postgres under load and both sets of numbers are worthless.
git clone https://github.com/MUKE-coder/grit && cd grit/benchmarks# the Grit side is scaffolded, not vendored — build it yourselfgrit new grit-bench --api(cd grit-bench && grit generate resource Product \name:string sku:string:unique price:float stock:int active:bool)docker compose up -d postgres./pair.sh bun # resets all databases, runs 3 x 4 scenarios, one app at a timepython pair-report.py bun
pair-report.py prints the app and database CPU for every row and labels which side saturated, so you can tell a real ceiling from a database limit. It refuses to report any scenario where a request failed.
Step-by-step guides





