
Grit vs Next.js
The claim worth testing is "I will just use Next for the backend too". This measures App Router route handlers, built with `next build` and served by the standalone output: not `next dev`, which recompiles on demand and would make the result meaningless.
Node 22, Next.js 15.5 App Router, standalone output, Prisma · v15.5 + Prisma
What you should end up with
| Scenario | Grit | Next.js | Ratio |
|---|---|---|---|
show GET /api/v1/products/:id | 6,822 req/s 5.5 ms · neither saturated | 433 req/s 103.0 ms · its own ceiling | 15.76× |
write POST /api/v1/products | 6,157 req/s 6.2 ms · neither saturated | 499 req/s 93.0 ms · its own ceiling | 12.34× |
list GET /api/v1/products?page=N&page_size=20 | 1,186 req/s 35.6 ms · database-bound | 247 req/s 189.7 ms · its own ceiling | 4.80× |
mixed a weighted blend of the three above | 1,053 req/s 38.5 ms · database-bound | 271 req/s 181.0 ms · its own ceiling | 3.89× |
Both figures come from the same run, minutes apart. Your absolute numbers will differ (different CPU, different disk, different background load) and so will ours: Grit's single-row read measured 4,536 req/s in the Bun pair and 8,509 in the Express pair from an identical binary, as hours of write scenarios accumulated in Postgres. The ratio is what should survive. If it does not, something in the setup differs and the steps below are where to look.
How Next.js is kept from being handicapped
A benchmark is only worth publishing if the loser was given every reasonable advantage. These are the specific decisions made for this framework, and each one is worth saying out loud on camera.
- Production build with `output: "standalone"`, which is what a real Next deployment ships.
- One worker per CPU via `cluster`, same as the Express app, so Next is not left on one core.
- Prisma, the ORM the Next.js ecosystem defaults to. Every framework in this benchmark uses its ecosystem’s ORM (Grit has GORM, Laravel has Eloquent, Django has its own) because comparing any of them against hand-written SQL measures the ORM rather than the framework.
- The route handlers are marked `dynamic = "force-dynamic"`. Without it Next may serve a cached response and you would be benchmarking a cache, not a framework.
- The Prisma client is a module-level singleton. A per-request client would open a connection storm: the same class of bug this benchmark found in Grit’s pool defaults.
Reproduce it, start to finish
- 1
Install the two tools you need
Docker runs every app and the database. k6 generates the load. Nothing else is required: you do not need Go, PHP, Python, Node or Bun installed locally, because every framework builds inside a container.
# macOSbrew install k6# Windowswinget install k6 --source winget# Linuxsudo gpg -k && sudo gpg --no-default-keyring \--keyring /usr/share/keyrings/k6-archive-keyring.gpg \--keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \| sudo tee /etc/apt/sources.list.d/k6.listsudo apt-get update && sudo apt-get install k6k6 versiondocker --version - 2
Clone the harness
Everything below lives in the benchmarks directory of the Grit repository: the compose file, the k6 script, the seed data, and each framework’s application.
git clone https://github.com/MUKE-coder/grit.gitcd grit/benchmarks - 3
Start Postgres and create the databases
One Postgres instance is shared by every framework, tuned once, with 8 CPUs: deliberately more than any application gets, so the database is never what gives out first. Each framework gets its own database so their schemas cannot collide.
docker compose up -d postgresfor db in bench_grit bench_encore bench_bun bench_express bench_nextjs bench_laravel bench_django; dodocker compose exec -T postgres psql -q -U bench -d bench -c "CREATE DATABASE $db;"done - 4
Create the table and load identical rows
This is the single most important step for a fair result. Every framework gets the same table, created from the same SQL file, holding the same 10,000 rows with the same UUIDs. Letting each ORM run its own migration would mean comparing schemas, not frameworks: Eloquent, Django and GORM disagree about integer widths, timestamp precision and which columns get indexed.
for db in bench_grit bench_encore bench_bun bench_express bench_nextjs bench_laravel bench_django; dodocker compose exec -T postgres psql -q -U bench -d $db < seed/schema.sqldocker compose exec -T postgres psql -q -U bench -d $db < seed/products.sqldone# every one should print 10000for db in bench_grit bench_encore bench_bun bench_express bench_nextjs bench_laravel bench_django; dodocker compose exec -T postgres psql -tA -U bench -d $db -c "SELECT count(*) FROM products;"doneIf any of these prints something other than 10000, stop and fix it. A benchmark where one side has more rows than another is measuring the row count.
- 5
Look at the route handlers
Two files: the collection at app/api/v1/products/route.ts and the item at app/api/v1/products/[id]/route.ts. Shared query logic and the type coercion live in lib/db.ts.
cat nextjs-bench/app/api/v1/products/route.tscat nextjs-bench/app/api/v1/products/\[id\]/route.tscat nextjs-bench/lib/db.ts - 6
Note force-dynamic, and why it matters
Next tries to make route handlers static when it can prove nothing varies per request. These read query parameters, so it would not, but stating it explicitly means you are never accidentally measuring a cached response.
export const dynamic = 'force-dynamic'export const runtime = 'nodejs'If you see suspiciously high numbers with near-zero CPU, this is the first thing to check. A cached route will happily serve tens of thousands of requests a second and tell you nothing.
- 7
Build and start it
The build runs `next build` in one stage and copies the standalone output into a clean runtime image, which is how a production Next container is put together.
docker compose build nextjsdocker compose up -d nextjs# should print: nextjs-bench: forking 4 workersdocker compose logs nextjs | head -3 - 8
Check every framework is on an ORM, not raw SQL
This is the fairness decision that matters most after the shared schema. Grit’s generated handlers use GORM and you cannot swap that out, so measuring any framework against hand-written SQL compares an ORM to no ORM, which flatters that framework for a reason that has nothing to do with it. Express on raw pg measured 2,000 req/s on writes; the same Express on Prisma measured 773. Same framework, same machine, same test.
# Grit GORM apps/api/internal/models/product.go# Laravel Eloquent laravel-bench/app/Models/Product.php# Django Django ORM django-bench/products/models.py# Express Prisma express-bench/prisma/schema.prisma# Next.js Prisma nextjs-bench/prisma/schema.prisma# Bun Drizzle bun-bench/schema.ts# Encore Drizzle encore-bench/products/schema.tsgrep -rl "prisma|drizzle|Eloquent|models.Model" express-bench nextjs-bench bun-bench encore-bench django-bench laravel-bench 2>/dev/null | headIf you swap any of these for raw SQL the numbers move enough to change the conclusion. That is the single easiest way to make this benchmark say whatever you want it to say.
- 9
Prove the two apps return the same bytes
Before measuring anything, confirm the framework you are testing returns exactly what Grit returns for the same record. If the payloads differ, say a missing field or a price as a string instead of a number, then the two are doing different amounts of work and the comparison is void.
ID=$(python -c "import json;print(json.load(open('seed/ids.json'))[0])")for app in grit nextjs; doecho "--- $app"docker run --rm --network benchmarks_default curlimages/curl -s \"http://$app:8080/api/v1/products/$ID"echodoneThis is the step most benchmarks skip, and it is the one that decides whether the rest means anything. Show it on camera.
- 10
Run the benchmark
Three repetitions of four scenarios, one application at a time with the others stopped. Each run resets the dataset first, discards a warm-up, and samples container CPU while the load is actually running.
APPS="grit nextjs" REPS=3 ./final.shpython aggregate.py
Reading your own results
Next.js route handlers land close to Express but not identical: you are paying for Next’s routing and request/response adapters on top of the same underlying Node and the same `pg` driver. The gap between them is a reasonable measure of what the framework layer costs.
aggregate.py prints the app container's CPU and Postgres's CPU next to every row. That pair is what tells you whether a number is a real ceiling. If the app is pinned near 400% then you are seeing its limit. If the app is idling while Postgres is near 800%, the database gave out first. That row is a floor, the framework would go faster on a bigger database, and quoting it as “X does N req/s” overstates what you measured.
The other guides




