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.
GET /jsonSerialize a small object: routing and HTTP overhead only.
Requests per secondhigher is better
p99 latencylower is better
99 of every 100 requests in the median run finished within this time, under the same load.
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
Idle memory lower is better
Startup time lower is better
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.
| NavreeRust | 214,2572.6 ms p99 | 46,5478.3 ms p99 | 6,12660 ms p99 | 15,45822 ms p99 | 20,29519 ms p99 | 23 ms | 4.2 MB | 38 MB | 137 MB |
|---|---|---|---|---|---|---|---|---|---|
| LocoRust | 541,7441.1 ms p99 | 76,0585.2 ms p99 | 6,43355 ms p99 | 16,20221 ms p99 | 34,30810 ms p99 | 3 ms | 5.5 MB | 42 MB | 133 MB |
| Axum + sqlxRust, micro | 962,8420.6 ms p99 | 107,9944.0 ms p99 | 7,10352 ms p99 | 24,40215 ms p99 | 49,0438.2 ms p99 | 3 ms | 3.1 MB | 31 MB | 116 MB |
| Actix Web + sqlxRust, micro | 1,233,0970.8 ms p99 | 78,1645.5 ms p99 | 6,37155 ms p99 | 22,07416 ms p99 | 44,5159.6 ms p99 | 3 ms | 4.2 MB | 24 MB | 122 MB |
| Gin + pgxGo, micro | 524,6314.2 ms p99 | 184,9353.1 ms p99 | 21,85617 ms p99 | 29,96412 ms p99 | 71,7476.7 ms p99 | 2 ms | 4.5 MB | 49 MB | 141 MB |
| ASP.NET Core + EF CoreC# | 496,9243.4 ms p99 | 77,4746.8 ms p99 | 6,31761 ms p99 | 20,35220 ms p99 | 29,24515 ms p99 | 513 ms | 35 MB | 208 MB | 337 MB |
| Spring Boot + JPAJava | 342,4295.6 ms p99 | 73,96519 ms p99 | 17,46046 ms p99 | 16,25665 ms p99 | 28,54030 ms p99 | 1,305 ms | 270 MB | 1.0 GB | 683 MB |
| NestJS (Express)TypeScript | 356,5282.2 ms p99 | 64,98811 ms p99 | 6,03674 ms p99 | 4,532129 ms p99 | 13,79033 ms p99 | 1,365 ms | 632 MB | 2.7 GB | 425 MB |
| NestJS (Fastify)TypeScript | 644,2801.3 ms p99 | 80,13212 ms p99 | 6,65379 ms p99 | 4,729108 ms p99 | 14,81731 ms p99 | 876 ms | 710 MB | 2.7 GB | 425 MB |
| Express + pgJavaScript, micro | 441,5932.0 ms p99 | 103,6105.2 ms p99 | 13,35930 ms p99 | 20,13529 ms p99 | 31,31818 ms p99 | 342 ms | 265 MB | 1.4 GB | 340 MB |
| FastAPI + SQLAlchemyPython, micro | 119,9044.7 ms p99 | 10,823140 ms p99 | 1,369502 ms p99 | 3,473605 ms p99 | 4,964706 ms p99 | 1,573 ms | 606 MB | 635 MB | 351 MB |
| Django + DRFPython | 25,48973 ms p99 | 7,535153 ms p99 | 1,471697 ms p99 | 2,236457 ms p99 | 3,437261 ms p99 | 859 ms | 653 MB | 1.0 GB | 271 MB |
| Laravel (Octane, FrankenPHP)PHP | 14,07828 ms p99 | 7,49350 ms p99 | 1,063281 ms p99 | 1,757176 ms p99 | 3,216115 ms p99 | 897 ms | 153 MB | 185 MB | 888 MB |
| Laravel (PHP-FPM + nginx)PHP | 5,62362 ms p99 | 1,575200 ms p99 | 928321 ms p99 | 927315 ms p99 | 1,095282 ms p99 | 275 ms | 80 MB | 239 MB | 760 MB |
| Ruby on RailsRuby | 41,51617 ms p99 | 16,87826 ms p99 | 3,137107 ms p99 | 3,28699 ms p99 | 4,92768 ms p99 | 1,369 ms | 160 MB | 802 MB | 505 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.
| Test | Navree | Axum + sqlx | Loco | Navree as a share |
|---|---|---|---|---|
| JSON | 214k | 963k | 542k | 22% of Axum, 40% of Loco |
| Single query | 46.5k | 108k | 76.1k | 43% of Axum, 61% of Loco |
| 20 queries | 6.1k | 7.1k | 6.4k | 86% of Axum, 95% of Loco |
| Auth + paginated list | 15.5k | 24.4k | 16.2k | 63% of Axum, 95% of Loco |
| Auth + validated insert | 20.3k | 49k | 34.3k | 41% 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:
navree-dbleaves sqlx'stest_before_acquireon, so every pool checkout pings PostgreSQL first:/dbcosts two round trips instead of one.- Every unit-of-work transaction adds a
SELECT set_config('navree.actor', …)round trip. - The unit-of-work layer spawns a task for every request,
/jsonincluded. - 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>). - 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.
CurrentUserfindsAuthServiceby scanning every provider, and loads every user column, including the password hash and the two-factor secrets.- JSON bodies are parsed twice: into a
serde_json::Value, then into the DTO. Repo::insertserializes 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.
| Test | First run | At the end | Change |
|---|---|---|---|
| JSON | 214,257 | 220,846 | +3.1% |
| Single query | 46,547 | 46,921 | +0.8% |
| 20 queries | 6,126 | 6,175 | +0.8% |
| Auth + paginated list | 15,458 | 15,449 | −0.1% |
| Auth + validated insert | 20,295 | 20,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-stack170/170 checks
| Framework | Navree 0.1.0 from this repository (crates/navree, a path dependency; features auth, log, postgres) |
|---|---|
| Runtime | Rust 1.99.1 (rust:1-bookworm build stage, debian:bookworm-slim runtime); release profile opt-level = 3, lto = "thin", codegen-units = 1 |
| HTTP | navree-http on Axum 0.8.9, hyper 1.12.0, tower-http 0.7.1 |
| Workers | One 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 |
| Database | navree-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) |
| JWT | navree-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) |
| Validation | navree-validation Valid<Query<T>> and Valid<Json<T>> on garde 0.23.0; failures are 400s in Navree's error format |
LocoRust, full-stack170/170 checks
| Runtime | Rust 1.99.1 (rust:1-bookworm build stage, debian:bookworm-slim runtime); release profile opt-level = 3, lto = "thin", codegen-units = 1 |
|---|---|
| Framework | Loco 1.2.0 (loco-rs, on Axum 0.8.9, hyper 1.12, Tokio 1.54.1), LOCO_ENV=production |
| Workers | One 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 |
| Database | SeaORM 2.0.4 on sqlx 0.9.0 (Postgres), Loco's pool from database: in config/production.yaml: max_connections: 64, min_connections: 1 |
| JWT | jsonwebtoken 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 |
| Validation | validator 0.20.0 (derive) through Loco's JsonValidateWithMessage (body) and QueryValidateWithMessage (query string) extractors |
Axum + sqlxRust, micro-framework170/170 checks
| Runtime | Rust 1.99.1 (rust:1-bookworm build stage, debian:bookworm-slim runtime); release profile opt-level = 3, lto = "thin", codegen-units = 1 |
|---|---|
| Framework | Axum 0.8.9 (hyper 1.12, Tokio 1.54.1) |
| Workers | One process on Tokio's default multi-thread runtime (#[tokio::main]): one worker thread per CPU, 8 under the benchmark's cpuset |
| Database | sqlx 0.9.0 (Postgres, runtime query functions, not the compile-time macros), one PgPool of 64 connections |
| JWT | jsonwebtoken 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 |
| Validation | validator 0.21.0 (derive) on serde-deserialized input |
Actix Web + sqlxRust, micro-framework170/170 checks
| Runtime | Rust 1.99.1 (rust:1-bookworm build stage, debian:bookworm-slim runtime); release profile opt-level = 3, lto = "thin", codegen-units = 1 |
|---|---|
| Framework | Actix Web 4.18.1 (actix-http 3.20, actix-server 2.10, Tokio 1.54.1), default features |
| Workers | Actix's default: one worker per CPU (available_parallelism(), 8 under the benchmark's cpuset), each a single-threaded Tokio runtime |
| Database | sqlx 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 |
| JWT | jsonwebtoken 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 |
| Validation | validator 0.21.0 (derive) on serde-deserialized input |
Gin + pgxGo, micro-framework170/170 checks
| Runtime | Go 1.27.3 (golang:1.27-bookworm to build, debian:bookworm-slim to run), a static binary (CGO_ENABLED=0, -trimpath -s -w) |
|---|---|
| Framework | Gin 1.12.0, GIN_MODE=release, gin.New() with Recovery only (no logger middleware), no trusted proxies |
| Server | Gin 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 |
| Database | pgx v5.11.0 with one pgxpool, MaxConns 64, plain SQL |
| JWT | golang-jwt/jwt v5.3.1 in a middleware: WithValidMethods(HS256), WithIssuer, WithExpirationRequired, then the user row is loaded (401 when it is missing) |
| Validation | Gin's binding tags (go-playground/validator v10.30.1) on the query and body structs; failures give 400 with a JSON body |
| UUIDs | google/uuid 1.6.0 (v4 for new deals) |
| Security updates | Indirect 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-stack170/170 checks
| 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 |
|---|---|
| Framework | ASP.NET Core 10 MVC controllers with [ApiController] and attribute routing |
| Server | Kestrel, one process, default thread pool; Server GC (the Web SDK default; DATAS is on by default since .NET 9); ASPNETCORE_ENVIRONMENT unset, so Production |
| Database | EF 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) |
| JWT | Microsoft.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 |
| Validation | DataAnnotations (Required, Range, StringLength, AllowedValues); [ApiController] returns the automatic 400 ValidationProblemDetails |
| Logging | Minimum level Warning (appsettings.json), no request logging |
Spring Boot + JPAJava, full-stack170/170 checks
| Runtime | Java 25 LTS, Eclipse Temurin 25.0.4.1 (eclipse-temurin:25-jre, Ubuntu 26.04/glibc), G1 GC, -XX:MaxRAMPercentage=75 |
|---|---|
| Framework | Spring Boot 4.1.2 (Spring Framework 7.0.10), Spring Web MVC |
| Server | Embedded Tomcat 11.0.27, one process, a virtual thread per request (spring.threads.virtual.enabled=true) |
| Database | Spring Data JPA 4.1.2 on Hibernate ORM 7.4.13, HikariCP 7.0.2 (one pool of 64), PostgreSQL JDBC 42.7.14 |
| JWT | Spring 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 |
| Validation | Jakarta Bean Validation (Hibernate Validator 9.1.5): @Valid on the request record, @Min/@Max on the query parameters |
| JSON | Jackson 3.1.7 |
| Build | Maven 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-stack170/170 checks
| Runtime | Node.js 24 (node:24-bookworm-slim), NODE_ENV=production |
|---|---|
| Framework | NestJS 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) |
| Workers | node:cluster in main.ts, one Nest app per CPU (os.availableParallelism(), 8 under the benchmark's cpuset) |
| Database | TypeORM 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 |
| Validation | class-validator 0.15 + class-transformer 0.5, global ValidationPipe({ transform: true }), DTOs for the list query and the create body |
| Serialization | ClassSerializerInterceptor on the deals controller; entities use @Exclude()/@Expose() |
| Logging | Nest logger limited to ['error', 'warn'], TypeORM logging off, no request logging |
NestJS (Fastify)TypeScript, full-stack170/170 checks
| Runtime | Node.js 24 (node:24-bookworm-slim), NODE_ENV=production |
|---|---|
| Framework | NestJS 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) |
| Workers | node:cluster in main.ts, one Nest app per CPU (os.availableParallelism(), 8 under the benchmark's cpuset) |
| Database | TypeORM 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 |
| Validation | class-validator 0.15 + class-transformer 0.5, global ValidationPipe({ transform: true }), DTOs for the list query and the create body |
| Serialization | ClassSerializerInterceptor on the deals controller; entities use @Exclude()/@Expose() |
| Logging | Nest logger limited to ['error', 'warn'], TypeORM logging off, no request logging |
Express + pgJavaScript, micro-framework170/170 checks
| Runtime | Node.js 24 (node:24-bookworm-slim) |
|---|---|
| Framework | Express 5.3 |
| Workers | node:cluster, one worker per CPU (os.availableParallelism(), 8 under the benchmark's cpuset) |
| Database | node-postgres (pg) 8, a pool of 8 per worker (64 in total), plain SQL |
| JWT | jsonwebtoken 9 (HS256, issuer checked), then the user row is loaded |
| Validation | zod 4 |
FastAPI + SQLAlchemyPython, micro-framework170/170 checks
| Runtime | CPython 3.14.9 (python:3.14.9-slim-bookworm) |
|---|---|
| Framework | FastAPI 0.162.0 (fastapi[standard]) on Starlette 1.10.0 |
| Server | Uvicorn 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 |
| Database | SQLAlchemy 2.1.6 async ORM (declarative models) on asyncpg 0.32.0, pool_size=8, max_overflow=0 per worker (64 in total) |
| JWT | PyJWT 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) |
| Validation | Pydantic 2.14.1: a body model for create, Query(ge=, le=) for page/per_page (FastAPI's 422); responses go through response_models |
| Lockfile | uv.lock, installed with uv sync --locked (uv 0.13.5) |
Django + DRFPython, full-stack170/170 checks
| Runtime | CPython 3.14.9 (python:3.14.9-slim-bookworm) |
|---|---|
| Framework | Django 6.1.2, Django REST Framework 3.18.3 |
| Server | Gunicorn 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 |
| Database | Django ORM on psycopg 3.3.6 (psycopg[binary]), persistent connections (CONN_MAX_AGE=None), one per Gunicorn thread: at most 51 |
| JWT | djangorestframework-simplejwt 5.5.1 (JWTAuthentication, configured through settings only) on PyJWT 2.15.1 |
| Validation | DRF serializers: a ModelSerializer for the create body, a plain serializer for page/per_page (400) |
| Lockfile | uv.lock, installed with uv sync --locked (uv 0.13.5) |
Laravel (Octane, FrankenPHP)PHP, full-stack170/170 checks
| Runtime | PHP 8.5.12 ZTS (dunglas/frankenphp:1.13.3-php8.5.12-trixie) |
|---|---|
| Server | Laravel 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 |
| Database | Eloquent over pdo_pgsql; one connection per worker, kept across requests (Octane default): 8 |
| Framework | Laravel 13.38 (skeleton 13.11, trimmed to an API: no frontend, migrations or tests) |
| ORM | Eloquent: World, User (Navree's users table, password in password_hash), Deal with belongsTo owner |
| JWT | php-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 |
| Validation | Form requests (ListDealsRequest, StoreDealRequest), Laravel's 422 |
| Serialization | API Resources (DealResource, OwnerResource) |
| Production settings | APP_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-stack170/170 checks
| Runtime | PHP 8.5.12 (php:8.5.12-fpm-trixie) + nginx 1.26.3 (Debian trixie) |
|---|---|
| Server | PHP-FPM behind nginx over a Unix socket, started by docker/fpm-start.sh |
| Workers | pm = static, pm.max_children = 64 (8 per CPU); nginx: one worker per CPU |
| Database | Eloquent over pdo_pgsql; one connection per request (Laravel default): at most 64 |
| Framework | Laravel 13.38 (skeleton 13.11, trimmed to an API: no frontend, migrations or tests) |
| ORM | Eloquent: World, User (Navree's users table, password in password_hash), Deal with belongsTo owner |
| JWT | php-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 |
| Validation | Form requests (ListDealsRequest, StoreDealRequest), Laravel's 422 |
| Serialization | API Resources (DealResource, OwnerResource) |
| Production settings | APP_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-stack170/170 checks
| Runtime | Ruby 4.0.9 with YJIT (ruby:4.0.9-slim-bookworm), jemalloc 5.3 via LD_PRELOAD |
|---|---|
| Framework | Rails 8.1.4, rails new --api (Action Controller API, Active Record, Active Model only) |
| Server | Puma 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 |
| Database | Active 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 |
| JWT | jwt 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 |
| Validation | Active Record validations on Deal plus strong params (params.expect); an Active Model form object (Pagination) for page/per_page |
| Serialization | render 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.
git clone https://github.com/NavreeHQ/navree
cd navree
node bench/run.mjs --smoke --only navree # build, conformance, then 5 s per test
node bench/run.mjs # the full run, one framework at a time
node bench/run.mjs --only navree,axum # or just some of them
node bench/report.mjs # writes bench/results/report.htmlRaw results
The files this page is built from. extra/ holds the drift check and the failed Laravel run.
- navree.json5 KB
- loco.json5 KB
- axum.json5 KB
- actix.json5 KB
- gin.json5 KB
- aspnet.json5 KB
- spring.json5 KB
- nestjs-express.json5 KB
- nestjs-fastify.json5 KB
- express.json5 KB
- fastapi.json5 KB
- django.json5 KB
- laravel-octane.json5 KB
- laravel-fpm.json5 KB
- rails.json5 KB
- extra/navree-run1.json5 KB
- extra/navree-control.json5 KB
- extra/laravel-fpm-default-sysctls.json5 KB
Source of each implementation
Each directory has a Dockerfile, the code and a README with versions and judgement calls.
- Navreeframeworks/navree
- Locoframeworks/loco
- Axum + sqlxframeworks/axum
- Actix Web + sqlxframeworks/actix
- Gin + pgxframeworks/gin
- ASP.NET Core + EF Coreframeworks/aspnet
- Spring Boot + JPAframeworks/spring
- NestJS (Express)frameworks/nestjs
- NestJS (Fastify)frameworks/nestjs
- Express + pgframeworks/express
- FastAPI + SQLAlchemyframeworks/fastapi
- Django + DRFframeworks/django
- Laravel (Octane, FrankenPHP)frameworks/laravel
- Laravel (PHP-FPM + nginx)frameworks/laravel
- Ruby on Railsframeworks/rails