navree

Benchmarks

Navree and 14 other web frameworks implement the same small API. Each one passed the same 170-check conformance test, then ran alone on the same machine, on the same CPUs, against the same PostgreSQL.

Measured on 26 October 2026. Every number on this page is read from the raw results when the site is built.

What the numbers say about Navree

  • On the realistic endpoints, Navree is 4th of the 10 full-stack frameworks on both. It serves 15,458 authenticated, paginated lists a second, behind ASP.NET Core + EF Core (20,352), Spring Boot + JPA (16,256) and Loco (16,202). That is 3.3× NestJS (Fastify), 4.7× Ruby on Rails, 6.9× Django + DRF and 8.8× Laravel (Octane, FrankenPHP).
  • Its own per-request cost is high. On plain JSON it is 10th of 15, at 214k requests a second, against 963k for Axum, the router it is built on, and 542k for Loco. On one query it is 10th of 15. Here is why.
  • Its footprint is tiny. It idles at 4.2 MB, peaks at 38 MB under load (3rd lowest of 15), is ready 23 ms after it starts, and returned no errors in any test.

Results

Pick a test. Bars show the median of 3 measured runs; hover or focus a bar for its latency and CPU use. micro marks micro-frameworks, measured with their most common libraries instead of a full stack.

Test
Frameworks

GET /jsonSerialize a small object: routing and HTTP overhead only.

Requests per secondhigher is better

  1. Actix Web + sqlxmicro1.23M
  2. Axum + sqlxmicro963k
  3. NestJS (Fastify)644k
  4. Loco542k
  5. Gin + pgxmicro525k
  6. ASP.NET Core + EF Core497k
  7. Express + pgmicro442k
  8. NestJS (Express)357k
  9. Spring Boot + JPA342k
  10. Navree214k
  11. FastAPI + SQLAlchemymicro120k
  12. Ruby on Rails41.5k
  13. Django + DRF25.5k
  14. Laravel (Octane, FrankenPHP)14.1k
  15. Laravel (PHP-FPM + nginx)5.6k

p99 latencylower is better

99 of every 100 requests in the median run finished within this time, under the same load.

  1. Axum + sqlxmicro0.6 ms
  2. Actix Web + sqlxmicro0.8 ms
  3. Loco1.1 ms
  4. NestJS (Fastify)1.3 ms
  5. Express + pgmicro2.0 ms
  6. NestJS (Express)2.2 ms
  7. Navree2.6 ms
  8. ASP.NET Core + EF Core3.4 ms
  9. Gin + pgxmicro4.2 ms
  10. FastAPI + SQLAlchemymicro4.7 ms
  11. Spring Boot + JPA5.6 ms
  12. Ruby on Rails17 ms
  13. Laravel (Octane, FrankenPHP)28 ms
  14. Laravel (PHP-FPM + nginx)62 ms
  15. Django + DRF73 ms

Memory and startup

Peak memory is the most the app's container used during any test. Startup is the time from starting the container to the first successful response.

Peak memory under load lower is better

  1. Actix Web + sqlxmicro24 MB
  2. Axum + sqlxmicro31 MB
  3. Navree38 MB
  4. Loco42 MB
  5. Gin + pgxmicro49 MB
  6. Laravel (Octane, FrankenPHP)185 MB
  7. ASP.NET Core + EF Core208 MB
  8. Laravel (PHP-FPM + nginx)239 MB
  9. FastAPI + SQLAlchemymicro635 MB
  10. Ruby on Rails802 MB
  11. Django + DRF1.0 GB
  12. Spring Boot + JPA1.0 GB
  13. Express + pgmicro1.4 GB
  14. NestJS (Express)2.7 GB
  15. NestJS (Fastify)2.7 GB

Idle memory lower is better

  1. Axum + sqlxmicro3.1 MB
  2. Navree4.2 MB
  3. Actix Web + sqlxmicro4.2 MB
  4. Gin + pgxmicro4.5 MB
  5. Loco5.5 MB
  6. ASP.NET Core + EF Core35 MB
  7. Laravel (PHP-FPM + nginx)80 MB
  8. Laravel (Octane, FrankenPHP)153 MB
  9. Ruby on Rails160 MB
  10. Express + pgmicro265 MB
  11. Spring Boot + JPA270 MB
  12. FastAPI + SQLAlchemymicro606 MB
  13. NestJS (Express)632 MB
  14. Django + DRF653 MB
  15. NestJS (Fastify)710 MB

Startup time lower is better

  1. Gin + pgxmicro2 ms
  2. Loco3 ms
  3. Axum + sqlxmicro3 ms
  4. Actix Web + sqlxmicro3 ms
  5. Navree23 ms
  6. Laravel (PHP-FPM + nginx)275 ms
  7. Express + pgmicro342 ms
  8. ASP.NET Core + EF Core513 ms
  9. Django + DRF859 ms
  10. NestJS (Fastify)876 ms
  11. Laravel (Octane, FrankenPHP)897 ms
  12. Spring Boot + JPA1,305 ms
  13. NestJS (Express)1,365 ms
  14. Ruby on Rails1,369 ms
  15. FastAPI + SQLAlchemymicro1,573 ms

Navree was ready in 23 ms, 5th of 15.

All results

Requests per second and p99 latency for every test, with startup, memory and image size. Sort by any column.

Requests per second (the median of the measured runs) and p99 latency for every test, with startup time, memory and image size. The column headings sort the table.
NavreeRust214,2572.6 ms p9946,5478.3 ms p996,12660 ms p9915,45822 ms p9920,29519 ms p9923 ms4.2 MB38 MB137 MB
LocoRust541,7441.1 ms p9976,0585.2 ms p996,43355 ms p9916,20221 ms p9934,30810 ms p993 ms5.5 MB42 MB133 MB
Axum + sqlxRust, micro962,8420.6 ms p99107,9944.0 ms p997,10352 ms p9924,40215 ms p9949,0438.2 ms p993 ms3.1 MB31 MB116 MB
Actix Web + sqlxRust, micro1,233,0970.8 ms p9978,1645.5 ms p996,37155 ms p9922,07416 ms p9944,5159.6 ms p993 ms4.2 MB24 MB122 MB
Gin + pgxGo, micro524,6314.2 ms p99184,9353.1 ms p9921,85617 ms p9929,96412 ms p9971,7476.7 ms p992 ms4.5 MB49 MB141 MB
ASP.NET Core + EF CoreC#496,9243.4 ms p9977,4746.8 ms p996,31761 ms p9920,35220 ms p9929,24515 ms p99513 ms35 MB208 MB337 MB
Spring Boot + JPAJava342,4295.6 ms p9973,96519 ms p9917,46046 ms p9916,25665 ms p9928,54030 ms p991,305 ms270 MB1.0 GB683 MB
NestJS (Express)TypeScript356,5282.2 ms p9964,98811 ms p996,03674 ms p994,532129 ms p9913,79033 ms p991,365 ms632 MB2.7 GB425 MB
NestJS (Fastify)TypeScript644,2801.3 ms p9980,13212 ms p996,65379 ms p994,729108 ms p9914,81731 ms p99876 ms710 MB2.7 GB425 MB
Express + pgJavaScript, micro441,5932.0 ms p99103,6105.2 ms p9913,35930 ms p9920,13529 ms p9931,31818 ms p99342 ms265 MB1.4 GB340 MB
FastAPI + SQLAlchemyPython, micro119,9044.7 ms p9910,823140 ms p991,369502 ms p993,473605 ms p994,964706 ms p991,573 ms606 MB635 MB351 MB
Django + DRFPython25,48973 ms p997,535153 ms p991,471697 ms p992,236457 ms p993,437261 ms p99859 ms653 MB1.0 GB271 MB
Laravel (Octane, FrankenPHP)PHP14,07828 ms p997,49350 ms p991,063281 ms p991,757176 ms p993,216115 ms p99897 ms153 MB185 MB888 MB
Laravel (PHP-FPM + nginx)PHP5,62362 ms p991,575200 ms p99928321 ms p99927315 ms p991,095282 ms p99275 ms80 MB239 MB760 MB
Ruby on RailsRuby41,51617 ms p9916,87826 ms p993,137107 ms p993,28699 ms p994,92768 ms p991,369 ms160 MB802 MB505 MB

Where Navree is slower

Navree does more work per request than the frameworks it is compared with, and some of that work is waste. The gap is largest where the framework is all there is to measure.

Requests per second of Navree, Axum and Loco, and Navree as a share of each
TestNavreeAxum + sqlxLocoNavree as a share
JSON214k963k542k22% of Axum, 40% of Loco
Single query46.5k108k76.1k43% of Axum, 61% of Loco
20 queries6.1k7.1k6.4k86% of Axum, 95% of Loco
Auth + paginated list15.5k24.4k16.2k63% of Axum, 95% of Loco
Auth + validated insert20.3k49k34.3k41% of Axum, 59% of Loco

All three run on Axum, Tokio and sqlx, and Navree and Loco both add SeaORM, so the difference is the framework. The time goes into Navree's request pipeline, not the database: on JSON, Navree used 772% of its 800% of CPU, and on one query 586% while PostgreSQL used only 329% of its 1,000%.

Reading the code at the time of the run shows where the time goes. Fixing these is the next piece of work:

  1. navree-db leaves sqlx's test_before_acquire on, so every pool checkout pings PostgreSQL first: /db costs two round trips instead of one.
  2. Every unit-of-work transaction adds a SELECT set_config('navree.actor', …) round trip.
  3. The unit-of-work layer spawns a task for every request, /json included.
  4. Process-wide locks are taken on every request: maintenance mode (a mutex and a read-write lock) and the per-IP rate limiter (one global Mutex<HashMap>).
  5. The request-context layer builds metrics labels (fresh strings) even when no metrics recorder is installed, plus a request scope and a UUID request id.
  6. CurrentUser finds AuthService by scanning every provider, and loads every user column, including the password hash and the two-factor secrets.
  7. JSON bodies are parsed twice: into a serde_json::Value, then into the DTO.
  8. Repo::insert serializes each new row for a change event even when nothing listens for change events.

What this means for an application: once a request does real database work, the gap narrows. On the authenticated list, Navree delivers 95% of Loco's throughput and 63% of hand-written Axum.

Methodology

One contract

Every implementation serves the same five endpoints, defined in bench/SPEC.md, from the same seeded PostgreSQL database:

  • JSON (GET /json): serialize a small object: routing and HTTP overhead only.
  • Single query (GET /db): one random row by primary key.
  • 20 queries (GET /queries?queries=20): 20 separate random-row queries per request.
  • Auth + paginated list (GET /api/deals): jWT check, user lookup, a count and one page of 20 deals with their owners.
  • Auth + validated insert (POST /api/deals): jWT check, user lookup, body validation and an INSERT … RETURNING.

The two /api/deals endpoints are what a typical API endpoint does: verify an HS256 JWT, load the user, validate the input, query or insert, and serialize the result.

A conformance gate

Before it is measured, each app must pass 170 checks in bench/tools/conformance.mjs: response shapes and types, the clamping of /queries, every way a token can be wrong, pagination order and edge cases, and validation failures. An app that fails is not benchmarked. All 15 passed.

Fairness rules

  • Release builds and production settings, with no per-request access logging.
  • Every app uses all 8 of its CPUs the way its framework recommends: one multi-threaded process, or one worker per CPU.
  • At most 64 database connections in total, across all processes.
  • Idiomatic stacks: full-stack frameworks use their own ORM, validation, auth and serializers; micro-frameworks use their most common companions.
  • No caching of any kind: every request reads the database.
  • The latest stable versions, pinned in lockfiles, on Debian-based images.

CPU pinning and timing

The machine has 24 logical CPUs, split so that nothing competes: the app gets 8 (0-7), PostgreSQL 10 (8-17) and the load generator, wrk, 6 (18-23). Only one app runs at a time, and its database is recreated from the same template before it starts.

Each test runs wrk with 6 threads and 256 connections: a 10-second warmup, then 3 measured runs of 15 seconds. The page reports the median run; memory and CPU are sampled while the test runs.

The drift check

A laptop can heat up and slow down over a long run, so Navree was measured first and again after every other framework, 78 minutes later. Every test landed within 3.1% of the first run, so the machine did not drift.

Navree: requests per second in the first run and in the control run at the end
TestFirst runAt the endChange
JSON214,257220,846+3.1%
Single query46,54746,921+0.8%
20 queries6,1266,175+0.8%
Auth + paginated list15,45815,449−0.1%
Auth + validated insert20,29520,190−0.5%

Caveats

The load generator limits the fastest JSON results

On JSON, wrk itself was close to its 600% of CPU for Actix Web + sqlx (586%), Axum + sqlx (563%), NestJS (Fastify) (499%) and Loco (454%), so their real ceilings are higher than shown. Every other result is limited by the app or by PostgreSQL.

Some tests are bound by the database

On 20 queries, PostgreSQL used at least 60% of its 1,000% of CPU for 11 of 15 frameworks, and up to 949%; on Auth + paginated list, 11 of them. That compresses the differences between fast frameworks. Drivers that cache prepared statements (pgx in Gin, JDBC in Spring Boot) cost PostgreSQL much less per query; the leaders of 20 queries are Gin + pgx and Spring Boot + JPA.

Laravel on PHP-FPM ran out of ports

By default, Laravel under PHP-FPM opens a new PostgreSQL connection for every request. With Docker's default kernel settings, the closed connections used up the container's local ports within seconds: 100% of Single query requests failed, 57% of 20 queries requests failed, 36% of Auth + paginated list requests failed and 20% of Auth + validated insert requests failed. The figures on this page come from a rerun with net.ipv4.tcp_tw_reuse=1, the standard fix, which let the kernel reuse those ports. Production setups usually put PgBouncer in front of PostgreSQL or use persistent connections. The failed run is kept.

wrk runs on the monotonic clock

Stock wrk times requests with the wall clock. Inside the Docker VM the wall clock occasionally stepped backwards, and wrk counted phantom timeouts. The wrk used here (bench/wrk) is patched to use the monotonic clock.

These are laptop numbers

Everything ran on one laptop (Intel Core Ultra 9 275HX, Docker Desktop on WSL2), on one Docker bridge network. Absolute figures will differ on servers; the ordering is what to look at.

Measured under the working name

Navree was developed and measured under its working name, Keel. The measured code is the same; only the names changed.

The stacks

What each implementation runs, from its README. The READMEs also record every judgement call, such as Navree's raised per-IP rate limit (wrk is a single client).

NavreeRust, full-stack
Navree: the stack
FrameworkNavree 0.1.0 from this repository (crates/navree, a path dependency; features auth, log, postgres)
RuntimeRust 1.99.1 (rust:1-bookworm build stage, debian:bookworm-slim runtime); release profile opt-level = 3, lto = "thin", codegen-units = 1
HTTPnavree-http on Axum 0.8.9, hyper 1.12.0, tower-http 0.7.1
WorkersOne process. AppBuilder::run builds Tokio's (1.54.1) default multi-thread runtime: one worker per CPU, 8 under the benchmark's cpuset. The serve command runs axum::serve on 0.0.0.0:8080
Databasenavree-db DatabaseModule: SeaORM 2.0.4 on sqlx 0.9.0 (PostgreSQL), one pool of 64 connections (database.max_connections = 64, migrate_on_boot = false)
JWTnavree-auth AuthModule: its JwtStrategy (jsonwebtoken 11.1.0 with navree-auth's HMAC-only crypto provider on RustCrypto hmac/sha2 (the measured run used jsonwebtoken's rust_crypto backend, which signs HS256 with the same crates); HS256, signature, exp, iss = navree, token type access), then the CurrentUser extractor loads the users row (navree-auth's user entity)
Validationnavree-validation Valid<Query<T>> and Valid<Json<T>> on garde 0.23.0; failures are 400s in Navree's error format
LocoRust, full-stack
Loco: the stack
RuntimeRust 1.99.1 (rust:1-bookworm build stage, debian:bookworm-slim runtime); release profile opt-level = 3, lto = "thin", codegen-units = 1
FrameworkLoco 1.2.0 (loco-rs, on Axum 0.8.9, hyper 1.12, Tokio 1.54.1), LOCO_ENV=production
WorkersOne process on Tokio's default multi-thread runtime (#[tokio::main]): one worker thread per CPU, 8 under the benchmark's cpuset. Started in Loco's ServerOnly mode, as cargo loco start does
DatabaseSeaORM 2.0.4 on sqlx 0.9.0 (Postgres), Loco's pool from database: in config/production.yaml: max_connections: 64, min_connections: 1
JWTjsonwebtoken 10.4.0 (rust_crypto, the version loco-rs 1.2 links for its own auth::jwt) in a custom extractor on top of Loco's token lookup and auth.jwt.secret config; HS256, exp/iss/sub required, iss must be navree, then the user row is loaded
Validationvalidator 0.20.0 (derive) through Loco's JsonValidateWithMessage (body) and QueryValidateWithMessage (query string) extractors
Axum + sqlxRust, micro-framework
Axum + sqlx: the stack
RuntimeRust 1.99.1 (rust:1-bookworm build stage, debian:bookworm-slim runtime); release profile opt-level = 3, lto = "thin", codegen-units = 1
FrameworkAxum 0.8.9 (hyper 1.12, Tokio 1.54.1)
WorkersOne process on Tokio's default multi-thread runtime (#[tokio::main]): one worker thread per CPU, 8 under the benchmark's cpuset
Databasesqlx 0.9.0 (Postgres, runtime query functions, not the compile-time macros), one PgPool of 64 connections
JWTjsonwebtoken 11.1.0 with the aws_lc_rs backend (HS256; exp, iss and sub required, iss must be navree), then the user row is loaded
Validationvalidator 0.21.0 (derive) on serde-deserialized input
Actix Web + sqlxRust, micro-framework
Actix Web + sqlx: the stack
RuntimeRust 1.99.1 (rust:1-bookworm build stage, debian:bookworm-slim runtime); release profile opt-level = 3, lto = "thin", codegen-units = 1
FrameworkActix Web 4.18.1 (actix-http 3.20, actix-server 2.10, Tokio 1.54.1), default features
WorkersActix's default: one worker per CPU (available_parallelism(), 8 under the benchmark's cpuset), each a single-threaded Tokio runtime
Databasesqlx 0.9.0 (Postgres, runtime query functions, not the compile-time macros), one PgPool of 64 connections created in main and shared by every worker through web::Data
JWTjsonwebtoken 11.1.0 with the aws_lc_rs backend (HS256; exp, iss and sub required, iss must be navree), then the user row is loaded
Validationvalidator 0.21.0 (derive) on serde-deserialized input
Gin + pgxGo, micro-framework
Gin + pgx: the stack
RuntimeGo 1.27.3 (golang:1.27-bookworm to build, debian:bookworm-slim to run), a static binary (CGO_ENABLED=0, -trimpath -s -w)
FrameworkGin 1.12.0, GIN_MODE=release, gin.New() with Recovery only (no logger middleware), no trusted proxies
ServerGin on net/http (r.Run), one process, a goroutine per connection. GOMAXPROCS is 8 under the benchmark's cpuset: the startup log line prints GOMAXPROCS=8, NumCPU=8
Databasepgx v5.11.0 with one pgxpool, MaxConns 64, plain SQL
JWTgolang-jwt/jwt v5.3.1 in a middleware: WithValidMethods(HS256), WithIssuer, WithExpirationRequired, then the user row is loaded (401 when it is missing)
ValidationGin's binding tags (go-playground/validator v10.30.1) on the query and body structs; failures give 400 with a JSON body
UUIDsgoogle/uuid 1.6.0 (v4 for new deals)
Security updatesIndirect modules raised above the versions Gin 1.12.0 requires, to clear OSV advisories: golang.org/x/crypto 0.58.0, x/net 0.61.0, x/sys 0.49.0, x/text 0.43.0, github.com/quic-go/quic-go 0.64.1 (and x/sync 0.24.0, which those need)
ASP.NET Core + EF CoreC#, full-stack
ASP.NET Core + EF Core: the stack
Runtime.NET 10.0.14 (LTS): mcr.microsoft.com/dotnet/sdk:10.0 (SDK 10.0.403) to build, mcr.microsoft.com/dotnet/aspnet:10.0 to run
FrameworkASP.NET Core 10 MVC controllers with [ApiController] and attribute routing
ServerKestrel, one process, default thread pool; Server GC (the Web SDK default; DATAS is on by default since .NET 9); ASPNETCORE_ENVIRONMENT unset, so Production
DatabaseEF Core 10.0.14 with Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 (Npgsql 10.0.3). AddDbContextPool, LINQ queries, AsNoTracking for reads; owners come from a projection (one JOIN). Maximum Pool Size=64 in the connection string, which is built from DATABASE_URL (or the DB_* variables)
JWTMicrosoft.AspNetCore.Authentication.JwtBearer 10.0.14 (Microsoft.IdentityModel 8.19.2): symmetric key, HS256 only (ValidAlgorithms), ValidIssuer, lifetime checked; MapInboundClaims = false so sub stays sub
ValidationDataAnnotations (Required, Range, StringLength, AllowedValues); [ApiController] returns the automatic 400 ValidationProblemDetails
LoggingMinimum level Warning (appsettings.json), no request logging
Spring Boot + JPAJava, full-stack
Spring Boot + JPA: the stack
RuntimeJava 25 LTS, Eclipse Temurin 25.0.4.1 (eclipse-temurin:25-jre, Ubuntu 26.04/glibc), G1 GC, -XX:MaxRAMPercentage=75
FrameworkSpring Boot 4.1.2 (Spring Framework 7.0.10), Spring Web MVC
ServerEmbedded Tomcat 11.0.27, one process, a virtual thread per request (spring.threads.virtual.enabled=true)
DatabaseSpring Data JPA 4.1.2 on Hibernate ORM 7.4.13, HikariCP 7.0.2 (one pool of 64), PostgreSQL JDBC 42.7.14
JWTSpring Security 7.1.2 OAuth2 resource server: NimbusJwtDecoder.withSecretKey(...) (nimbus-jose-jwt 10.9.1), HS256, JwtValidators.createDefaultWithIssuer (exp/nbf + iss), then the User entity is loaded
ValidationJakarta Bean Validation (Hibernate Validator 9.1.5): @Valid on the request record, @Min/@Max on the query parameters
JSONJackson 3.1.7
BuildMaven 3.10.0 in maven:3.10.0-eclipse-temurin-25. Every dependency and plugin version is pinned by the exact spring-boot-starter-parent version (Maven has no lockfile).
NestJS (Express)TypeScript, full-stack
NestJS (Express): the stack
RuntimeNode.js 24 (node:24-bookworm-slim), NODE_ENV=production
FrameworkNestJS 12.2 (@nestjs/core, @nestjs/common), ESM like the nest new template; built with nest build (CLI 12.0) and TypeScript 6.0, run from dist/
HTTP@nestjs/platform-express 12.2 (Express 5.3, multer 2.5.0) or @nestjs/platform-fastify 12.2 (Fastify 5.12)
Workersnode:cluster in main.ts, one Nest app per CPU (os.availableParallelism(), 8 under the benchmark's cpuset)
DatabaseTypeORM 1.1 through @nestjs/typeorm 12.0, pg 8.23 driver; repositories injected into services; pool of floor(64 / workers) per worker (extra: { max }, 8 × 8 = 64)
JWT@nestjs/passport 12.0 + passport-jwt 4.0 (jsonwebtoken 9): JwtStrategy with ExtractJwt.fromAuthHeaderAsBearerToken(), HS256, issuer checked, AuthGuard('jwt') on the deals controller
Validationclass-validator 0.15 + class-transformer 0.5, global ValidationPipe({ transform: true }), DTOs for the list query and the create body
SerializationClassSerializerInterceptor on the deals controller; entities use @Exclude()/@Expose()
LoggingNest logger limited to ['error', 'warn'], TypeORM logging off, no request logging
NestJS (Fastify)TypeScript, full-stack
NestJS (Fastify): the stack
RuntimeNode.js 24 (node:24-bookworm-slim), NODE_ENV=production
FrameworkNestJS 12.2 (@nestjs/core, @nestjs/common), ESM like the nest new template; built with nest build (CLI 12.0) and TypeScript 6.0, run from dist/
HTTP@nestjs/platform-express 12.2 (Express 5.3, multer 2.5.0) or @nestjs/platform-fastify 12.2 (Fastify 5.12)
Workersnode:cluster in main.ts, one Nest app per CPU (os.availableParallelism(), 8 under the benchmark's cpuset)
DatabaseTypeORM 1.1 through @nestjs/typeorm 12.0, pg 8.23 driver; repositories injected into services; pool of floor(64 / workers) per worker (extra: { max }, 8 × 8 = 64)
JWT@nestjs/passport 12.0 + passport-jwt 4.0 (jsonwebtoken 9): JwtStrategy with ExtractJwt.fromAuthHeaderAsBearerToken(), HS256, issuer checked, AuthGuard('jwt') on the deals controller
Validationclass-validator 0.15 + class-transformer 0.5, global ValidationPipe({ transform: true }), DTOs for the list query and the create body
SerializationClassSerializerInterceptor on the deals controller; entities use @Exclude()/@Expose()
LoggingNest logger limited to ['error', 'warn'], TypeORM logging off, no request logging
Express + pgJavaScript, micro-framework
Express + pg: the stack
RuntimeNode.js 24 (node:24-bookworm-slim)
FrameworkExpress 5.3
Workersnode:cluster, one worker per CPU (os.availableParallelism(), 8 under the benchmark's cpuset)
Databasenode-postgres (pg) 8, a pool of 8 per worker (64 in total), plain SQL
JWTjsonwebtoken 9 (HS256, issuer checked), then the user row is loaded
Validationzod 4
FastAPI + SQLAlchemyPython, micro-framework
FastAPI + SQLAlchemy: the stack
RuntimeCPython 3.14.9 (python:3.14.9-slim-bookworm)
FrameworkFastAPI 0.162.0 (fastapi[standard]) on Starlette 1.10.0
ServerUvicorn 0.54.0 with uvloop 0.23.0 and httptools 0.9.0 (uvicorn[standard]), one worker process per CPU (--workers from WEB_CONCURRENCY = nproc, 8 under the benchmark's cpuset), --no-access-log
DatabaseSQLAlchemy 2.1.6 async ORM (declarative models) on asyncpg 0.32.0, pool_size=8, max_overflow=0 per worker (64 in total)
JWTPyJWT 2.15.1 (HS256, issuer="navree", exp/iss/sub required) in a Depends(get_current_user) dependency behind HTTPBearer; it loads the user with session.get(User, sub) and raises HTTPException(401)
ValidationPydantic 2.14.1: a body model for create, Query(ge=, le=) for page/per_page (FastAPI's 422); responses go through response_models
Lockfileuv.lock, installed with uv sync --locked (uv 0.13.5)
Django + DRFPython, full-stack
Django + DRF: the stack
RuntimeCPython 3.14.9 (python:3.14.9-slim-bookworm)
FrameworkDjango 6.1.2, Django REST Framework 3.18.3
ServerGunicorn 26.2.0, gthread worker class, (2 × CPUs) + 1 = 17 workers × 3 threads (from os.process_cpu_count(), 8 under the benchmark's cpuset), no access log; see gunicorn.conf.py
DatabaseDjango ORM on psycopg 3.3.6 (psycopg[binary]), persistent connections (CONN_MAX_AGE=None), one per Gunicorn thread: at most 51
JWTdjangorestframework-simplejwt 5.5.1 (JWTAuthentication, configured through settings only) on PyJWT 2.15.1
ValidationDRF serializers: a ModelSerializer for the create body, a plain serializer for page/per_page (400)
Lockfileuv.lock, installed with uv sync --locked (uv 0.13.5)
Laravel (Octane, FrankenPHP)PHP, full-stack
Laravel (Octane, FrankenPHP): the stack
RuntimePHP 8.5.12 ZTS (dunglas/frankenphp:1.13.3-php8.5.12-trixie)
ServerLaravel Octane 2.22 on FrankenPHP 1.13.3 (Caddy 2.11.7), worker mode
Workers--workers=$(nproc): one per CPU, 8 under the benchmark's cpuset; Octane's default --max-requests=500
DatabaseEloquent over pdo_pgsql; one connection per worker, kept across requests (Octane default): 8
FrameworkLaravel 13.38 (skeleton 13.11, trimmed to an API: no frontend, migrations or tests)
ORMEloquent: World, User (Navree's users table, password in password_hash), Deal with belongsTo owner
JWTphp-open-source-saver/jwt-auth 2.9.3 (lcobucci/jwt 5.6.1), the jwt guard as auth:api, user loaded by sub through the Eloquent provider
ValidationForm requests (ListDealsRequest, StoreDealRequest), Laravel's 422
SerializationAPI Resources (DealResource, OwnerResource)
Production settingsAPP_ENV=production, APP_DEBUG=false, LOG_CHANNEL=stderr, LOG_LEVEL=warning; php.ini-production plus OPcache with validate_timestamps=0 (docker/php.ini); php artisan optimize (config, events, routes, views) at container start; APP_KEY generated at build time
Laravel (PHP-FPM + nginx)PHP, full-stack
Laravel (PHP-FPM + nginx): the stack
RuntimePHP 8.5.12 (php:8.5.12-fpm-trixie) + nginx 1.26.3 (Debian trixie)
ServerPHP-FPM behind nginx over a Unix socket, started by docker/fpm-start.sh
Workerspm = static, pm.max_children = 64 (8 per CPU); nginx: one worker per CPU
DatabaseEloquent over pdo_pgsql; one connection per request (Laravel default): at most 64
FrameworkLaravel 13.38 (skeleton 13.11, trimmed to an API: no frontend, migrations or tests)
ORMEloquent: World, User (Navree's users table, password in password_hash), Deal with belongsTo owner
JWTphp-open-source-saver/jwt-auth 2.9.3 (lcobucci/jwt 5.6.1), the jwt guard as auth:api, user loaded by sub through the Eloquent provider
ValidationForm requests (ListDealsRequest, StoreDealRequest), Laravel's 422
SerializationAPI Resources (DealResource, OwnerResource)
Production settingsAPP_ENV=production, APP_DEBUG=false, LOG_CHANNEL=stderr, LOG_LEVEL=warning; php.ini-production plus OPcache with validate_timestamps=0 (docker/php.ini); php artisan optimize (config, events, routes, views) at container start; APP_KEY generated at build time

A new PostgreSQL connection per request (Laravel default under FPM) exhausts the container's ephemeral ports (TIME_WAIT) at ~1,400 connections/s; tcp_tw_reuse lets the kernel reuse them.

Ruby on RailsRuby, full-stack
Ruby on Rails: the stack
RuntimeRuby 4.0.9 with YJIT (ruby:4.0.9-slim-bookworm), jemalloc 5.3 via LD_PRELOAD
FrameworkRails 8.1.4, rails new --api (Action Controller API, Active Record, Active Model only)
ServerPuma 8.0.2 in cluster mode: 8 workers (WEB_CONCURRENCY=8, one per CPU), 5 threads each (RAILS_MAX_THREADS=5), app preloaded and forked
DatabaseActive Record 8.1 + pg 1.7.0 (precompiled gem with its own libpq 18), prepared statements on; pool = threads = 5 per worker, 40 connections in total
JWTjwt 3.3.0 (HS256, exp checked, iss: "navree" with verify_iss: true) in an Authentication controller concern; token parsed with Rails' authenticate_with_http_token, user loaded into Current.user
ValidationActive Record validations on Deal plus strong params (params.expect); an Active Model form object (Pagination) for page/per_page
Serializationrender json: with Active Model's serializable_hash (only: / include: :owner)

Reproduce it yourself

You need Docker, Node.js and a machine with 24 logical CPUs, or change --app-cpus, --pg-cpus and --load-cpus to fit yours. The script starts PostgreSQL, builds wrk and every app, and writes one JSON file per framework.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks