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
| Soli | 1.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 |
| Rails | 8.1.3 + Puma 8.0.2 on Ruby 3.4.9 — production, eager-loaded, 16 workers × 5 threads, PostgreSQL via ActiveRecord |
| Laravel | 13.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 + Octane | The 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 |
| Django | 6.0.7 on Python 3.14, gunicorn with 16 workers — Django ORM + Django templates, DEBUG=False, persistent connections (CONN_MAX_AGE) |
| FastAPI | 0.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 |
| Phoenix | 1.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 |
| AdonisJS | 6.18 on Node 25.9, 16 cluster workers — Lucid ORM + Edge templates, built to JavaScript and run from build/, NODE_ENV=production |
| Express | 5.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 |
| Database | PostgreSQL 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 |
| Load | oha 1.12 — 30s at concurrency 200 per cell, after an 8s warm-up of that cell |
| Machine | 16-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
| Stack | req/s | p99 | CPU/req | vs Rails |
|---|---|---|---|---|
| Express + EJS + Sequelize | 112,128 | 5.72 ms | — | 7.5× |
| Soli | 110,967 | 4.28 ms | — | 7.4× |
| FastAPI + SQLAlchemy + Jinja2 | 88,455 | 7.09 ms | 145 µs | 5.9× |
| Phoenix + Ecto + HEEx | 64,772 | 8.26 ms | 208 µs | 4.3× |
| AdonisJS + Lucid + Edge | 21,329 | 19.63 ms | 648 µs | 1.4× |
| Django + gunicorn | 17,049 | 21.15 ms | 690 µs | 1.1× |
| Rails + Puma | 14,944 | 23.66 ms | 847 µs | 1.0× |
| Laravel + Octane reference | 9,563 | 30.35 ms | 1,491 µs | 0.6× |
| Laravel + php-fpm | 4,773 | 52.40 ms | 3,019 µs | 0.3× |
def rows
return (0..50).map(fn(i) {
return { "id": i + 1, "title": "Post title #{i + 1}", "views": (i + 1) * 7 }
})
end
def json_only
render_json(this.rows())
enddef rows
(1..50).map { |i| { id: i, title: "Post title #{i}", views: i * 7 } }
end
def json_only
render json: rows
endconst rows = () =>
Array.from({ length: 50 }, (_, i) =>
({ id: i + 1, title: `Post title ${i + 1}`, views: (i + 1) * 7 }));
app.get('/json', (req, res) => res.json(rows()));private rows() {
return Array.from({ length: 50 }, (_, i) => ({
id: i + 1, title: `Post title ${i + 1}`, views: (i + 1) * 7,
}))
}
async jsonOnly({ response }: HttpContext) {
return response.json(this.rows())
}private function rows(): array
{
$out = [];
for ($i = 1; $i <= 50; $i++) {
$out[] = (object) ['id' => $i, 'title' => "Post title {$i}", 'views' => $i * 7];
}
return $out;
}
public function jsonOnly() { return response()->json($this->rows()); }def _rows():
return [{"id": i, "title": f"Post title {i}", "views": i * 7} for i in range(1, 51)]
def json_only(request):
# compact separators, or the payload is 299 bytes larger than everyone else's
return JsonResponse(_rows(), safe=False, json_dumps_params={"separators": (",", ":")})def rows():
return [{"id": i, "title": f"Post title {i}", "views": i * 7} for i in range(1, 51)]
# Returning a Response directly skips jsonable_encoder — the cost no other
# stack here pays. The default path is the /json-encoded reference row.
@app.get("/json")
async def json_only():
return JSONResponse(rows())
@app.get("/json-encoded")
async def json_encoded():
return rows()defp rows do
Enum.map(1..50, fn i -> %{id: i, title: "Post title #{i}", views: i * 7} end)
end
def json_only(conn, _params), do: json(conn, rows())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
| Stack | req/s | p99 | CPU/req | vs Rails |
|---|---|---|---|---|
| Soli | 124,933 | 3.80 ms | — | 10.7× |
| Express + EJS + Sequelize | 65,088 | 8.75 ms | 205 µs | 5.6× |
| Phoenix + Ecto + HEEx | 62,033 | 8.08 ms | 220 µs | 5.3× |
| FastAPI + SQLAlchemy + Jinja2 | 37,357 | 19.25 ms | 361 µs | 3.2× |
| AdonisJS + Lucid + Edge | 21,058 | 18.44 ms | 656 µs | 1.8× |
| Rails + Puma | 11,647 | 34.85 ms | 1,111 µs | 1.0× |
| Laravel + Octane reference | 8,611 | 30.21 ms | 1,703 µs | 0.7× |
| Django + gunicorn | 6,978 | 37.53 ms | 2,003 µs | 0.6× |
| Laravel + php-fpm | 3,822 | 83.93 ms | 3,378 µs | 0.3× |
def template_only
render("posts/list", { "title": "Posts", "items": this.rows() })
enddef template_only
@title = "Posts"
render "posts/list", locals: { items: rows }
endapp.get('/template', (req, res) =>
res.type('html').send(layout({ title: 'Posts', body: list({ items: rows() }) })));async templateOnly({ view }: HttpContext) {
return view.render('posts/list', { title: 'Posts', items: this.rows() })
}public function templateOnly()
{
return view('posts.list', ['title' => 'Posts', 'items' => $this->rows()]);
}def template_only(request):
return render(request, "posts/list.html", {"title": "Posts", "items": _rows()})# keep_trailing_newline: Jinja2 strips a template's last newline by default,
# which off Django's own template file would make this page a byte shorter.
templates = Jinja2Templates(env=jinja2.Environment(
loader=jinja2.FileSystemLoader(TEMPLATE_DIR),
autoescape=True, keep_trailing_newline=True, auto_reload=False,
))
@app.get("/template")
async def template_only(request: Request):
return templates.TemplateResponse(
request, "posts/list.html", {"title": "Posts", "items": rows()}
)# BOTH layouts must be off. `layout: false` disables only the inner one; the
# router's put_root_layout is separate, and leaving it on nested a second
# <!DOCTYPE html> inside <body> — 3,492 bytes that render without complaint.
defp bare(conn), do: conn |> put_root_layout(html: false) |> put_layout(html: false)
def template_only(conn, _params) do
conn |> bare() |> render(:list, title: "Posts", items: rows())
endSoli 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
| Stack | req/s | p99 | CPU/req | vs Rails |
|---|---|---|---|---|
| Soli | 49,707 | 5.04 ms | 196 µs (248 incl. SoliDB) | 5.1× |
| Phoenix + Ecto + HEEx | 31,346 | 15.88 ms | 361 µs | 3.2× |
| Express + EJS + Sequelize | 26,993 | 18.45 ms | 418 µs | 2.8× |
| AdonisJS + Lucid + Edge | 13,870 | 26.91 ms | 938 µs | 1.4× |
| FastAPI + SQLAlchemy + Jinja2 | 11,548 | 71.34 ms | 1,093 µs | 1.2× |
| Django + gunicorn | 9,950 | 25.50 ms | 1,236 µs | 1.0× |
| Rails + Puma | 9,696 | 34.81 ms | 1,303 µs | 1.0× |
| Laravel + Octane reference | 7,752 | 31.87 ms | 1,734 µs | 0.8× |
| Laravel + php-fpm | 3,663 | 63.00 ms | 3,712 µs | 0.4× |
# The builder is bound to a local BEFORE render_json: passed inline as a call
# argument it is evaluated twice and the query goes out twice.
def db_json
let rows = Post.pluck(:id, :title, :views).all
return render_json(rows)
end# pluck returns arrays, so the map is what makes the payload match the others.
def db_json
render json: Post.pluck(:id, :title, :views)
.map { |id, title, views| { id: id, title: title, views: views } }
end// raw: true projects without instantiating models — the analogue of pluck.
const ORM_PROJECTION = { attributes: ['id', 'title', 'views'], raw: true };
app.get('/db', async (req, res) => res.json(await PostModel.findAll(ORM_PROJECTION)));// Projection without hydrating models — the Lucid analogue of pluck.
// node-postgres renders int8 as a string, and the primary key is bigint, so
// start/pg_types.ts registers a parser or the payload stops matching.
private dbRows() {
return db.from('posts').select('id', 'title', 'views')
}
async dbJson({ response }: HttpContext) {
return response.json(await this.dbRows())
}// toBase() is Eloquent's own way to project without hydrating models.
private function dbRows()
{
return Post::query()->toBase()->select('id', 'title', 'views')->get();
}
public function dbJson() { return response()->json($this->dbRows()); }# .values() projects without instantiating model objects.
def _db_rows():
return list(Post.objects.values("id", "title", "views"))
def db_json(request):
return JsonResponse(_db_rows(), safe=False, json_dumps_params=COMPACT)# select() on mapped columns projects without hydrating objects — SQLAlchemy's
# analogue of pluck / raw:true / toBase() / .values(). Compiled once.
POSTS = select(Post.id, Post.title, Post.views)
async def db_rows():
async with Session() as session:
result = await session.execute(POSTS)
return [dict(row) for row in result.mappings()]
@app.get("/db")
async def db_json():
return JSONResponse(await db_rows())# A select map projects in the database without loading structs — Ecto's
# 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})
defp db_rows, do: Repo.all(@posts)
def db_json(conn, _params), do: json(conn, db_rows())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, /db | req/s | SoliDB CPU/req |
|---|---|---|
| with the cache what this table shows | 38,348 | 116 µs |
| with the cache off | 22,693 | 309 µ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 above | 11,662 | 63.28 ms | 9,738 | 72.42 ms |
| 20 reference | 12,488 | 44.59 ms | 10,185 | 58.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 projecting | req/s | vs its own projected row |
|---|---|---|
FastAPI, 50 mapped SQLAlchemy objects (select(Post)) | 8,662 | 0.74× |
Django, 50 model objects (.only() instead of .values()) | 9,275 | 0.90× |
Phoenix, 50 Ecto structs (Repo.all(Post)) | 32,813 | 1.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.
| Stack | req/s | p99 | CPU/req | vs Rails |
|---|---|---|---|---|
| Soli | 58,591 | 4.33 ms | 155 µs (206 incl. SoliDB) | 7.0× |
| Phoenix + Ecto + HEEx | 31,605 | 14.97 ms | 362 µs | 3.8× |
| Express + EJS + Sequelize | 23,229 | 17.77 ms | 513 µs | 2.8× |
| AdonisJS + Lucid + Edge | 13,161 | 29.12 ms | 1,001 µs | 1.6× |
| FastAPI + SQLAlchemy + Jinja2 | 9,467 | 89.26 ms | 1,291 µs | 1.1× |
| Rails + Puma | 8,355 | 38.93 ms | 1,529 µs | 1.0× |
| Laravel + Octane reference | 6,983 | 36.85 ms | 1,950 µs | 0.8× |
| Django + gunicorn | 5,395 | 44.76 ms | 2,547 µs | 0.6× |
| Laravel + php-fpm | 3,421 | 72.58 ms | 3,973 µs | 0.4× |
def db_template
render("posts/list", { "title": "Posts", "items": Post.pluck(:id, :title, :views).all })
enddef db_template
@title = "Posts"
render "posts/list", locals: {
items: Post.pluck(:id, :title, :views)
.map { |id, title, views| { id: id, title: title, views: views } }
}
endapp.get('/db-template', async (req, res) => {
const rows = await PostModel.findAll(ORM_PROJECTION);
res.type('html').send(layout({ title: 'Posts', body: list({ items: rows }) }));
});async dbTemplate({ view }: HttpContext) {
return view.render('posts/list', { title: 'Posts', items: await this.dbRows() })
}public function dbTemplate()
{
return view('posts.list', ['title' => 'Posts', 'items' => $this->dbRows()]);
}def db_template(request):
return render(request, "posts/list.html", {"title": "Posts", "items": _db_rows()})@app.get("/db-template")
async def db_template(request: Request):
return templates.TemplateResponse(
request, "posts/list.html", {"title": "Posts", "items": await db_rows()}
)def db_template(conn, _params) do
conn |> bare() |> render(:list, title: "Posts", items: db_rows())
endSoli 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.