Benchmark methodology
GritvsEncore.ts

Grit vs Encore.ts

Encore is the closest thing to a peer in this comparison. Like Grit it generates infrastructure rather than handing you a bare router, and its HTTP layer is written in Rust, so it is genuinely fast rather than fast-for-JavaScript. If any framework here was going to make Grit work for the win, it is this one.

Encore.ts 1.57, Rust runtime, Drizzle ORM · v1.57 + Drizzle

What you should end up with

ScenarioGritEncore.tsRatio
show
GET /api/v1/products/:id
6,646 req/s
5.3 ms · neither saturated
663 req/s
71.9 ms · neither saturated
10.02×
write
POST /api/v1/products
4,345 req/s
8.7 ms · neither saturated
854 req/s
51.9 ms · neither saturated
5.09×
list
GET /api/v1/products?page=N&page_size=20
617 req/s
67.0 ms · database-bound
249 req/s
166.0 ms · neither saturated
2.48×
mixed
a weighted blend of the three above
834 req/s
50.7 ms · database-bound
363 req/s
138.0 ms · neither saturated
2.30×

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 Encore.ts 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.

  • Built with Encore’s own CLI via `encore build docker`. There is no plain `tsc && node` path: the framework compiles your handlers into a Rust-backed runtime, and that runtime is most of why Encore is fast. Building it any other way would not be benchmarking Encore.
  • Drizzle over Encore’s own SQLDatabase connection, which is the ORM path Encore’s docs describe. Encore still provisions and manages the database; Drizzle is handed its connection string. Every framework here uses an ORM, so none of them is compared against hand-written SQL.
  • Raw endpoints (`api.raw`) rather than typed ones. Encore’s typed API gives you validation and a generated client for free, but it also owns the request and response shapes, and every framework here has to emit the same {data, meta} envelope at the same paths. Raw keeps that possible without asking Encore to do less work than the others.
  • The migration creates exactly the table in seed/schema.sql, so Encore agrees with every other framework about column types and indexes.
  • Same 4 CPUs and 2 GB as everyone else, against the same shared Postgres.

Reproduce it, start to finish

  1. 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.

    # macOS
    brew install k6
    # Windows
    winget install k6 --source winget
    # Linux
    sudo gpg -k && sudo gpg --no-default-keyring \
    --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
    --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
    echo "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.list
    sudo apt-get update && sudo apt-get install k6
    k6 version
    docker --version
  2. 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.git
    cd grit/benchmarks
  3. 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 postgres
    for db in bench_grit bench_encore bench_bun bench_express bench_nextjs bench_laravel bench_django; do
    docker compose exec -T postgres psql -q -U bench -d bench -c "CREATE DATABASE $db;"
    done
  4. 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; do
    docker compose exec -T postgres psql -q -U bench -d $db < seed/schema.sql
    docker compose exec -T postgres psql -q -U bench -d $db < seed/products.sql
    done
    # every one should print 10000
    for db in bench_grit bench_encore bench_bun bench_express bench_nextjs bench_laravel bench_django; do
    docker compose exec -T postgres psql -tA -U bench -d $db -c "SELECT count(*) FROM products;"
    done

    If 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. 5

    Look at the Encore service

    encore-bench/products/products.ts is the whole application: a database declaration, two raw endpoints and a health check. The migration beside it creates the shared table.

    cat encore-bench/products/products.ts
    cat encore-bench/products/migrations/1_create_products.up.sql
  6. 6

    Build it with Encore’s CLI, inside a container

    The Encore CLI is Linux-only, so encore-build.sh runs it in a container with the Docker socket mounted: that way `encore build docker` produces the image on your host daemon without you needing to install anything.

    bash encore-build.sh
    docker images | grep benchmarks-encore

    This step takes a few minutes the first time: it downloads the CLI, installs dependencies and compiles the Rust runtime. That is normal: do not cut it from the video, it is the part that explains why Encore is quick.

  7. 7

    Point Encore at the shared Postgres

    Encore normally provisions its own infrastructure. For a benchmark it has to use the same database as everyone else, which is what the infra config does: it maps Encore’s `bench` database onto bench_encore on the shared server.

    cat encore-bench/infra.json
    # create the database and load the same rows as every other framework
    docker compose exec -T postgres psql -q -U bench -d bench -c "CREATE DATABASE bench_encore;"
    docker compose exec -T postgres psql -q -U bench -d bench_encore < seed/schema.sql
    docker compose exec -T postgres psql -q -U bench -d bench_encore < seed/products.sql
  8. 8

    Start it and check the payload

    The image is already built, so compose just runs it with the infra config mounted.

    docker compose up -d encore
    ID=$(python -c "import json;print(json.load(open('seed/ids.json'))[0])")
    docker run --rm --network benchmarks_default curlimages/curl -s "http://encore:8080/api/v1/products/$ID"
  9. 9

    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.ts
    grep -rl "prisma|drizzle|Eloquent|models.Model" express-bench nextjs-bench bun-bench encore-bench django-bench laravel-bench 2>/dev/null | head

    If 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.

  10. 10

    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 encore; do
    echo "--- $app"
    docker run --rm --network benchmarks_default curlimages/curl -s \
    "http://$app:8080/api/v1/products/$ID"
    echo
    done

    This is the step most benchmarks skip, and it is the one that decides whether the rest means anything. Show it on camera.

  11. 11

    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 encore" REPS=3 ./final.sh
    python aggregate.py

Reading your own results

Encore is the fastest JavaScript-side framework in this comparison by a wide margin, and the gap to Grit is the narrowest of any framework here. That is the honest result and it is more interesting than a blowout: a Rust HTTP layer in front of Node closes most, though not all, of the distance to a Go binary.

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