ESC
Type to search...
S
Soli Docs

Benchmarks

Seven HTTP workloads, plus WebSockets — a JSON API response, a rendered HTML page, a database read, a database-backed HTML page, and one create, update and delete per request — through eight full stacks on one machine, one load generator, one protocol. Every server returns a byte-identical payload for the JSON and DB rows, and every stack gets the same 16-core budget — 16 workers for the six that fork them, 16 threads for Soli, 16 BEAM schedulers for Phoenix.

Read this first. One quiet session, same seven workloads, byte-identical JSON payloads, 16 cores each, every status code checked. Soli uses SoliDB over the native MessagePack driver (SOLI_DB_DRIVER=1). WebSocket figures are older and labelled where they appear.

Setup

Soli1.27.2, soli serve ., 16 HTTP workers, SoliDB over the native MessagePack driver (SOLI_DB_DRIVER=1, pooled TCP, not HTTP) for the DB and write rows
Rails8.1.3 + Puma 8.0.2 on Ruby 3.4.9 — production, eager-loaded, 16 workers × 5 threads, PostgreSQL via ActiveRecord
Laravel13.8 on PHP 8.4 (php-fpm, pm = static, 16 workers) + nginx, in Docker with host networking — Eloquent + Blade, OPcache, config/route/view cached, persistent PDO connections
Laravel + OctaneThe same application on Octane 2.18 / FrankenPHP, 16 workers, app resident between requests. Published as a labelled reference row, not as "Laravel", because it roughly doubles every result and is a deployment choice rather than the default
Django6.0.7 on Python 3.14, gunicorn with 16 workers — Django ORM + Django templates, DEBUG=False, persistent connections (CONN_MAX_AGE)
FastAPI0.141.1 on Python 3.14 (Starlette 1.3), uvicorn 0.52 with 16 workers on uvloop + httptools — SQLAlchemy 2.0.51 async + asyncpg 0.31 + Jinja2 3.1: FastAPI ships no ORM and no view layer, so both were added, the same way they were for Express. The published rows return a Response directly rather than paying for jsonable_encoder, which nothing else here pays for; the default path is a labelled reference row below
Phoenix1.8.9 on Elixir 1.17 / Erlang OTP 27, Bandit — one OS process, 16 BEAM schedulers (+S 16:16), Ecto + HEEx, MIX_ENV=prod, Phoenix's default :browser pipeline on the HTML rows. force_ssl removed from the generated prod config: left in, every request is a 301 and a load generator counts those as success
AdonisJS6.18 on Node 25.9, 16 cluster workers — Lucid ORM + Edge templates, built to JavaScript and run from build/, NODE_ENV=production
Express5.2.1 on Node 25.9, 16 cluster workers — + EJS 6.0 + Sequelize 6.37.8 (on node-postgres 8.22): Express ships no view layer and no DB layer, both had to be added. The DB rows put it on an ORM, not the raw driver, so all three stacks compare like for like; the driver number is kept below as a reference
DatabasePostgreSQL 18.3 for Rails, Express, AdonisJS, Laravel, Django, FastAPI and Phoenix (same table, same 50 rows); SoliDB for Soli — all client-server over a local socket, no in-process storage anywhere
Loadoha 1.12 — 30s at concurrency 200 per cell, after an 8s warm-up of that cell
Machine16-core x86-64 Linux, load generator on the same box

CPU/req is server CPU-time per request, summed across every process of the stack (all 17 for Rails, Express, AdonisJS and Django; 18 for FastAPI, whose uvicorn supervisor also spawns a multiprocessing resource tracker; php-fpm plus nginx for Laravel; a single process for Soli and for Phoenix, whose threads /proc already aggregates). It is the most portable column here — unlike req/s it barely moves with core count or client speed.

One trap this measurement had to survive. uvicorn's 16 workers are multiprocessing.spawn children, so a worker's command line reads python3 -c from multiprocessing.spawn import spawn_main; ... — the app's name appears nowhere in it. Summing CPU by command-line pattern, the way the Django column is summed (gunicorn.*benchproj), matched the supervisor alone: 8 CPU ticks against the process group's 88, which would have published a FastAPI CPU/req roughly ten times too good. The workers do share the supervisor's process group, so this column and the memory table both measure FastAPI by pgid. It is the same failure mode as the Octane memory bug noted further down — a pattern that silently matches a subset always errs in the flattering direction.

JSON — 50 objects, 2,268 bytes, built in the handler

Stackreq/sp99CPU/reqvs Rails
Express + EJS + Sequelize112,1285.72 ms—7.5×
Soli110,9674.28 ms—7.4×
FastAPI + SQLAlchemy + Jinja288,4557.09 ms145 µs5.9×
Phoenix + Ecto + HEEx64,7728.26 ms208 µs4.3×
AdonisJS + Lucid + Edge21,32919.63 ms648 µs1.4×
Django + gunicorn17,04921.15 ms690 µs1.1×
Rails + Puma14,94423.66 ms847 µs1.0×
Laravel + Octane reference9,56330.35 ms1,491 µs0.6×
Laravel + php-fpm4,77352.40 ms3,019 µs0.3×

Soli and Express are a dead heat on pure JSON (~111k req/s, two interleaved runs). Soli has the tighter p99. Rails is ~7× slower. Framework overhead only — no database. render_json now uses the same sonic-rs path as JSON.stringify.

Template — 50-row HTML table + layout, ~3 KB

Stackreq/sp99CPU/reqvs Rails
Soli124,9333.80 ms—10.7×
Express + EJS + Sequelize65,0888.75 ms205 µs5.6×
Phoenix + Ecto + HEEx62,0338.08 ms220 µs5.3×
FastAPI + SQLAlchemy + Jinja237,35719.25 ms361 µs3.2×
AdonisJS + Lucid + Edge21,05818.44 ms656 µs1.8×
Rails + Puma11,64734.85 ms1,111 µs1.0×
Laravel + Octane reference8,61130.21 ms1,703 µs0.7×
Django + gunicorn6,97837.53 ms2,003 µs0.6×
Laravel + php-fpm3,82283.93 ms3,378 µs0.3×

Soli leads templates: ~125k req/s, ~2× Express, ~11× Rails. ERB is cheaper here than render_json on the same 50 rows.

Database read — 50 rows, projected columns, 2,268 bytes

Stackreq/sp99CPU/reqvs Rails
Soli49,7075.04 ms196 µs (248 incl. SoliDB)5.1×
Phoenix + Ecto + HEEx31,34615.88 ms361 µs3.2×
Express + EJS + Sequelize26,99318.45 ms418 µs2.8×
AdonisJS + Lucid + Edge13,87026.91 ms938 µs1.4×
FastAPI + SQLAlchemy + Jinja211,54871.34 ms1,093 µs1.2×
Django + gunicorn9,95025.50 ms1,236 µs1.0×
Rails + Puma9,69634.81 ms1,303 µs1.0×
Laravel + Octane reference7,75231.87 ms1,734 µs0.8×
Laravel + php-fpm3,66363.00 ms3,712 µs0.4×
# analogue of pluck / raw:true / toBase() / .values(). Built at compile time. @posts from(p in Post, select: %{id: p.id, title: p.title, views: p.views})

This row compares database access architectures as much as frameworks. Each request from Soli is one MessagePack round trip on a pooled TCP driver connection to SoliDB per worker — 16 workers × a 5-socket pool, so up to 80 in flight, the same concurrency budget the other stacks give PostgreSQL. Rails holds 80 threads against PostgreSQL; Phoenix runs one Ecto pool of 80 across the whole VM; Express's driver is fully asynchronous behind a 5-per-worker cap.

Every stack goes through an ORM in this table. An earlier revision measured Express on the raw pg driver, which is not the same workload: hand-written SQL with no model layer against frameworks paying one. Putting Sequelize in the path costs Express 33% — 41,812 req/s on the driver against 28,148 through the ORM — and that is the number published, because every other row includes its ORM too. If your Node app talks to the database through a driver rather than an ORM, the driver row is the one that describes you. Soli's CPU column shows both truths: 196µs in the Soli process, 248µs system-wide once SoliDB's own CPU is counted — publishing the smaller number alone would hide an entire process. Even counted that way Soli's DB row costs 5.3× less system CPU than Rails', and the 16-in-flight cap is not the ceiling it looked like: it is enough to lead this row.

These two rows include a read-result cache that no other stack here has, and that is a problem with them. SoliDB memoizes read-only cursor results per (database, query, bind vars) and replays repeats with executionTimeMs: 0.0. The benchmark issues the same query every request and never writes posts, so the hit rate is 100% for the life of the run. Measured as a same-session A/B on this app, with the cached arm reproducing the published figure to within 7%:

Soli, /dbreq/sSoliDB CPU/req
with the cache what this table shows38,348116 µs
with the cache off22,693309 µs

That is 1.7×, and it is not a like-for-like advantage. PostgreSQL's buffer cache spares the disk but still re-plans, re-executes and re-serialises every request; SoliDB returns the memoized result set and does no query work at all. The nearest equivalent for the other stacks would be Rails.cache.fetch or Django's cache_page around the action — which this page would rightly refuse to count. Applying that ratio, Soli would fall from 1st to roughly 3rd on both database rows, behind Phoenix and Express.

The rows above are therefore not corrected yet, and should not be quoted as a like-for-like database comparison. Correcting them needs a re-sweep with SOLI_DB_NO_QUERY_CACHE=1 (the flag exists for exactly this) on a box that passes control.sh; the derived figures are deliberately not written into the table, because a measured table should hold measured numbers.

Phoenix is the peer on architecture (one process, pooled sockets). Soli still leads the DB row: 49.7k vs 31.3k req/s, 248µs vs 361µs system-wide, tighter p99.

FastAPI's p99 is the outlier on this row, and it is not the framework. 63.28 ms against Django's 24.09 ms, while FastAPI serves more throughput (11,662 against 10,342) — the means are nearly equal (17.2 ms against 19.3 ms at concurrency 200), so what differs is entirely the tail. The cause is the shape of the queue. Django's 16 gunicorn workers each handle one request at a time, so 184 of the 200 wait in one kernel accept queue: strictly FIFO, and a narrow distribution. FastAPI accepts all 200 into 16 event loops, where ~12 coroutines per worker then contend for 5 pool slots and one GIL — and that contention is not FIFO, so some requests are served promptly while others wait several times the mean. Async concurrency in front of a bounded pool does not remove the queue; it moves it somewhere less fair. The pool is matched to every other stack's (5 per worker, 80 total) and Express runs the same cap at a p99 of 15.50 ms, so this is specific to the Python async stack rather than to the pool size — measured directly below rather than asserted.

Is the matched pool what produces that tail? Partly, and it is worth measuring instead of arguing about. Raising FastAPI's pool from the matched 5 per worker to 20 — from 80 connections to 320, four times what any other stack here gets — gives:

FastAPI, pool per worker/db req/s/db p99/db-template req/s/db-template p99
5 matched, published above11,66263.28 ms9,73872.42 ms
20 reference12,48844.59 ms10,18558.09 ms

Two conclusions, and they point in different directions. The pool is not what caps FastAPI's throughput — quadrupling it buys 5–7%, so the ceiling is CPU inside SQLAlchemy, and the matched configuration is not handicapping the published rows. But the pool is a real part of the tail: p99 falls by 30% on the JSON read and 20% on the rendered page. Even so, 44.59 ms at 320 connections is still 1.85× Django's 24.09 ms at 16 connections and nearly the same throughput — so most of the tail survives the fix, and belongs to coroutine and GIL contention rather than to connection scarcity.

Every stack serves the same self-describing hash rows on its fastest idiom for that shape: Soli's Post.pluck(:id, :title, :views).all builds the hashes in the database (RETURN {id: doc.id, ...}); Rails' is pluck + a map; FastAPI's is select(Post.id, Post.title, Post.views) through an AsyncSession; node-postgres builds the objects in the driver. Projection must happen in the database — the hydrating form of the same query costs every ORM real money, measured here for the two Python stacks:

Instead of projectingreq/svs its own projected row
FastAPI, 50 mapped SQLAlchemy objects (select(Post))8,6620.74×
Django, 50 model objects (.only() instead of .values())9,2750.90×
Phoenix, 50 Ecto structs (Repo.all(Post))32,8131.02×

This is a clean three-way comparison, because Post has exactly the three columns the projection selects — so all three ORMs fetch identical bytes in both forms and the only difference is what they build from them. The cost of hydration is therefore 26% for SQLAlchemy, 10% for Django, and nothing measurable for Ecto (1.02× is inside run-to-run noise). Building a %Post{} struct is a map literal; building a mapped SQLAlchemy object registers it in an identity map and installs instrumented attributes on it, and that is what the 26% pays for.

Three figures on this theme were measured in an earlier session and are labelled as such rather than restated as current. Two make the same point as the table: Rails' canonical-looking render json: Post.select(:id, :title, :views) measured 3.2× slower than its pluck row because it instantiates fifty ActiveRecord models per request, and Soli fetching full ~15 KB documents to project client-side measured 13,251 req/s against its projected row. The third is a separate lever worth knowing: if you can accept positional arrays instead of hashes, everyone gets faster — on the array form of this same route Soli measured 26,666 and Express 44,515, because the field names stop being repeated fifty times on the wire and in the parser.

Database read + HTML render — 50 rows from the database into a page, ~3 KB

The row a server-rendered framework actually lives on: query, then render. It is the /db read and the /template render in one request, so the response is the same page as the Template row above, byte-for-byte the same size, and the database is the only added variable.

Stackreq/sp99CPU/reqvs Rails
Soli58,5914.33 ms155 µs (206 incl. SoliDB)7.0×
Phoenix + Ecto + HEEx31,60514.97 ms362 µs3.8×
Express + EJS + Sequelize23,22917.77 ms513 µs2.8×
AdonisJS + Lucid + Edge13,16129.12 ms1,001 µs1.6×
FastAPI + SQLAlchemy + Jinja29,46789.26 ms1,291 µs1.1×
Rails + Puma8,35538.93 ms1,529 µs1.0×
Laravel + Octane reference6,98336.85 ms1,950 µs0.8×
Django + gunicorn5,39544.76 ms2,547 µs0.6×
Laravel + php-fpm3,42172.58 ms3,973 µs0.4×

Soli takes this row — 7.0× Rails' throughput, 2.5× Express's and 1.9× Phoenix's — and it is the row whose shape matters most, because it is the only one where every stack does the two things a page does, each through its own ORM. Express on the raw driver reaches 32,579 here, still short of Soli with an ORM in the way.

DB + HTML ≈ DB alone. 58.6k vs 49.7k — once a query is in the request, JSON vs HTML is noise.