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 six 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 runs 16 workers.

Read this first. Benchmarks are easy to rig and easy to get wrong. Everything below was measured in one session on a quiet box, after a warm-up pass over all nine endpoints, with the HTTP status of every response verified — and where Soli loses a cell, the number is printed exactly as measured. The per-operation Soli-vs-Ruby language tables that used to live on this page were retired with it; this page compares frameworks, end to end.

Setup

Soli1.25.0, soli serve ., 16 HTTP workers, SoliDB (loopback HTTP) for the DB row
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)
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 and Django (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; php-fpm plus nginx for Laravel). It is the most portable column here — unlike req/s it barely moves with core count or client speed.

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

Stackreq/sp99CPU/reqvs Rails
Express + EJS + Sequelize110,1826.63 ms110 µs8.1×
Soli70,3986.44 ms191 µs5.2×
AdonisJS + Lucid + Edge22,46117.96 ms619 µs1.7×
Django + gunicorn17,95215.53 ms677 µs1.3×
Rails + Puma13,53528.58 ms941 µs1.0×
Laravel + php-fpm5,10846.18 ms2,838 µs0.4×
Laravel + Octane reference10,85426.24 ms1,349 µs0.8×

Soli serialises the API response at 5.2× Rails' throughput on 4.9× less CPU. Express wins this row outright — printed as measured. Worth knowing: in Soli, serialising these 50 objects to JSON costs ~191µs where rendering the same data as HTML costs 98µs — the template engine is currently cheaper than render_json.

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

Stackreq/sp99CPU/reqvs Rails
Soli124,3144.27 ms98 µs11.6×
Express + EJS + Sequelize65,7628.32 ms203 µs6.1×
AdonisJS + Lucid + Edge22,30916.75 ms576 µs2.1×
Rails + Puma10,72435.91 ms1,205 µs1.0×
Django + gunicorn7,08634.55 ms2,011 µs0.7×
Laravel + php-fpm4,80149.38 ms3,062 µs0.4×
Laravel + Octane reference9,27528.56 ms1,597 µs0.9×

The strongest row, and the one a server-rendered framework should care about: 11.6× Rails' throughput on 12.3× less CPU — and 1.9× faster than Express even though Soli's page carries its instant-navigation script (~130 extra bytes of work per request that EJS doesn't do). Soli's ERB engine outrunning a compiled EJS template was not a given.

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

Stackreq/sp99CPU/reqvs Rails
Soli35,4726.93 ms221 µs (339 incl. SoliDB)3.9×
Express + EJS + Sequelize26,68915.67 ms425 µs2.9×
AdonisJS + Lucid + Edge13,95826.69 ms928 µs1.5×
Django + gunicorn9,84225.12 ms1,236 µs1.1×
Rails + Puma9,12238.00 ms1,351 µs1.0×
Laravel + php-fpm3,77361.23 ms3,569 µs0.4×
Laravel + Octane reference8,25630.02 ms1,639 µs0.9×

This row compares database access architectures as much as frameworks. Each request from Soli is one blocking HTTP round trip to SoliDB per worker — 16 in flight, no more. Rails holds 80 threads against PostgreSQL; Express's driver is fully asynchronous, effectively unbounded — which is most of why it still leads here.

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 two frameworks paying one. Putting Sequelize in the path costs Express 34% — 42,818 req/s on the driver against 28,388 through the ORM — and that is the number published, because Soli's and Rails' rows include their ORMs 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: 221µs in the Soli process, 339µ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 4.0× 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.

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 — the canonical-looking render json: Post.select(:id, :title, :views) measured 3.2× slower (3,481 req/s), because it instantiates fifty ActiveRecord models per request; node-postgres builds the objects in the driver. Two lessons worth taking home: projection must happen in the database (Soli fetching full ~15 KB documents and projecting client-side measured 13,251 req/s), and if you can accept positional arrays instead of hashes, everyone gets faster — Soli measured 26,666 and Express 44,515 on the array form of the same route.

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
Soli38,6166.53 ms187 µs (304 incl. SoliDB)4.9×
Express + EJS + Sequelize23,00816.12 ms513 µs2.9×
AdonisJS + Lucid + Edge13,66726.65 ms955 µs1.7×
Rails + Puma7,84043.44 ms1,577 µs1.0×
Django + gunicorn5,43844.85 ms2,545 µs0.7×
Laravel + php-fpm3,54863.55 ms3,822 µs0.5×
Laravel + Octane reference7,32833.75 ms1,868 µs0.9×

Soli takes this row — 4.9× Rails' throughput and 1.7× Express'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,416 here, still short of Soli with an ORM in the way.

The result worth pausing on is Soli's own: this row and the JSON row above are within 9% of each other (38,616 and 35,472) on the same query and the same 50 rows. Once a database round trip is in the request it dominates — whether the result leaves as JSON or as a rendered page is close to noise. The large render-path gap the JSON and Template rows show on in-memory data does not survive contact with a real query, which is worth knowing before optimising a template for a page that is really waiting on the database.

Writes — create, update and delete, one row per request

The read rows above are only half of CRUD. These three measure a single write per request against an isolated 800,000-row table, reset to exactly that state before every cell so no stack inherits a table another stack grew or emptied. Update and delete address one row by primary key, drawn at random from the same 1..800,000 range in every stack.

Durability is matched, and that matters more than anything else here. SoliDB's writes go through RocksDB's default write path — the WAL reaches the operating system but is not fsynced before the write returns. PostgreSQL's default (synchronous_commit=on) does flush before commit returns, which is a stronger guarantee and a much slower one. Compared head to head that way, Soli would be winning an argument the other stacks were not having. So PostgreSQL runs with synchronous_commit=off for these rows, the setting that matches what SoliDB actually promises: survive a process crash, not a power cut. Neither column is "durable writes" — read them as buffered writes on both sides.

Octane roughly doubles Laravel on every row (1.8× to 2.1×), which is the whole reason it is published separately. On php-fpm the framework is rebuilt per request; on Octane it stays in memory, and that single change moves Laravel from last place into the same band as Rails and Django — it beats Django on the template, database-backed page and delete rows. Read the php-fpm rows as the default deployment and the Octane rows as what the same code does when you opt into the resident runtime.

Create — one INSERT per request

Stackreq/sp99CPU/reqvs Rails
Soli32,6078.99 ms132 µs (344 incl. SoliDB)3.9×
Express + EJS + Sequelize22,81619.67 ms487 µs2.7×
AdonisJS + Lucid + Edge14,37226.95 ms881 µs1.7×
Django + gunicorn10,94922.83 ms1,077 µs1.3×
Rails + Puma8,32046.55 ms1,494 µs1.0×
Laravel + php-fpm3,72661.47 ms3,659 µs0.4×
Laravel + Octane reference8,38330.05 ms1,599 µs1.0×

Update — one row by primary key

Stackreq/sp99CPU/reqvs Rails
Soli31,6938.73 ms131 µs (359 incl. SoliDB)3.0×
Express + EJS + Sequelize21,85618.50 ms503 µs2.1×
AdonisJS + Lucid + Edge14,94026.74 ms817 µs1.4×
Rails + Puma10,43842.26 ms1,173 µs1.0×
Django + gunicorn10,11224.93 ms1,168 µs1.0×
Laravel + php-fpm3,81160.21 ms3,570 µs0.4×
Laravel + Octane reference8,56628.57 ms1,545 µs0.8×

Delete — one row by primary key

Stackreq/sp99CPU/reqrows removedvs Rails
Express + EJS + Sequelize30,91016.40 ms324 µs2.9×59% of requests
Soli29,5888.99 ms150 µs (399 incl. SoliDB)2.7×60% of requests
AdonisJS + Lucid + Edge14,99427.11 ms832 µs1.4×77% of requests
Rails + Puma10,83735.40 ms1,151 µs1.0×82% of requests
Django + gunicorn8,84328.81 ms1,316 µs0.8×85% of requests
Laravel + php-fpm3,86959.10 ms3,534 µs0.4×93% of requests
Laravel + Octane reference8,72528.07 ms1,525 µs0.8×88% of requests

Read the delete row with its caveat. Delete is the one operation that consumes its own workload: a key already deleted is a miss, and a miss is cheaper than a delete. The "rows removed" column is measured, not assumed — the table is counted before and after each cell — and it shows the effect plainly: the faster a stack runs, the more of the 800,000-row pool it exhausts and the higher its miss rate climbs. Rails, at a third of the throughput, does the largest share of real deletes. So this row understates the gap to Rails and should be read as a rough ordering, not a precise multiple. The create and update rows have no such problem: every create inserts, and every update targets a key that still exists.

Two things stand out across all three. Soli's own CPU per write is 8 to 10× lower than Rails' and roughly a third of Express's, but the system-wide figure — the number in parentheses, which counts SoliDB's process too — is where the honest comparison sits, and even there Soli leads. And every stack writes far more slowly than it reads: Soli's create row is 32,607 against 124,314 on the in-memory template row, because a write has to reach another process and a log, whichever framework issued it.

WebSockets — echo and fan-out

oha speaks HTTP only, so these use a purpose-built client. Three stacks appear here: Soli, Express and Rails. Rails' ActionCable mounts inside the same Puma process serving the HTTP rows, on the redis adapter — the async adapter keeps its pubsub in-process, so with 16 workers a broadcast would reach only the worker that received it. It also speaks a JSON subprotocol rather than raw WebSocket, so the client performs the full welcome → subscribe → confirm handshake before any message is timed. Django (Channels on ASGI) and Laravel (Reverb) would each need a different server process from the one serving their HTTP rows, so they are absent rather than misrepresented.

Echo — round trip, one message in flight per connection

Stackmsg/sp50p99connections
Express + ws411,3402.01 ms6.83 ms1,000
Soli241,7904.02 ms6.20 ms1,000
Rails + ActionCable98,5290.58 ms63.83 ms1,000

Express takes this one — printed as measured — at roughly 1.7× Soli's message rate and half the median latency.

Do not read Rails' p50 as a win. At 1,000 connections and 98,529 msg/s the mean latency must be 10.15 ms, seventeen times its median: the distribution is bimodal, not fast. ActionCable dispatches through a bounded worker pool (four threads per Puma worker by default), so a subset of sockets is served almost immediately while the rest queue — which is exactly what a p50 of 0.58 ms against a p99 of 63.83 ms describes. Soli and Express keep p99 within ~3× of their medians; Rails' spread is 110×. When a median and a mean disagree by that much, the median is the misleading one.

Fan-out — one publisher, every connection in the room receives

Stackreached per publishshare of the roomdeliveries/s
Soli, 16 workers1,000 of 1,000100%45,264
Rails + ActionCable, 16 workers + Redis1,000 of 1,000100%45,545
Express + ws, 16 workers + Redis1,000 of 1,000100%44,217
Express + ws, 1 worker1,000 of 1,000100%44,106
Express + ws, 16 workers, no bus63 of 1,0006%2,846

Read this as an architecture row, not a speed row. Once the broadcast is actually complete all four are the same — 45,264, 45,545, 44,217 and 44,106 deliveries/s are one number. Neither Express nor Rails is slow at fan-out, and Rails — whose echo throughput is a third of Soli's — matches it exactly here, because delivering to a room is dominated by the sockets, not the framework. The difference is what it takes to get there. Soli's 16 workers are threads in one process, so a broadcast reaches every connection the server holds, with nothing to configure. Node's cluster gives each worker its own sockets, so the obvious implementation reaches only the ~1/16th that worker accepted — the last row, and note that it does not error, it just silently delivers to 6% of the room. Fixing it means either dropping to one worker (correct, and one sixteenth of the HTTP capacity) or adding Redis and a hop per publish (correct, same throughput, one more thing to run and to fail). The honest shape of it: equal throughput, unequal defaults — the naive Soli implementation is right, the naive clustered-Node one is quietly wrong.

Connection cost is close: both accept about 6,000 connections/sec once warm, and 1,000 idle sockets cost Soli ~19 KB each against Express's ~28 KB. Soli's first few hundred connections are slower while handlers warm — a cold-start effect, not a steady-state one.

Two things this measurement had to get right, because both produced confident wrong answers first. A single Node client saturates long before either server: it reported 74k msg/s against Soli where eight client processes reported 238k, and throughput fell as connections rose while latency scaled linearly — the signature of a bottlenecked generator, not a server limit. And an unthrottled publisher outruns the server, because a publish costs the client nothing and the server N sends; flat out, fan-out read 9.4 of 2,000 and was measuring the client's send loop. The published numbers use eight sharded clients and a rate-limited publisher.

Memory

StackProcessesIdleUnder load
Soli1 × 16 threads50 MB70 MB
Laravel + php-fpm17 (fpm + nginx)104 MB131 MB
Laravel + Octane reference16 resident workers200 MB214 MB
Rails + Puma17 (fork + CoW)255 MB924 MB
Express + EJS + Sequelize17 (fork + CoW)442 MB1,055 MB
Django + gunicorn17 (fork + CoW)648 MB921 MB
AdonisJS + Lucid + Edge17 (fork + CoW)2,815 MB3,068 MB

Figures are PSS (proportional set size) summed over the whole process group — the honest measure for multi-process servers, because summing RSS counts every fork-shared page 17 times. "Under load" is read at the end of a 30s run on the DB + HTML route. Soli is one process whose 16 workers are threads; the others fork 16 processes with a heap apiece.

At idle Soli runs in a fifth of Rails' memory and a thirteenth of Django's. Two results are worth reading together. Laravel on php-fpm is the second-leanest at 104 MB — because it is slowest: the framework is not resident between requests, so there is little to hold. Octane makes the opposite trade, keeping the application in memory: it roughly doubles Laravel's throughput and roughly doubles its memory, 200 MB against 104. AdonisJS is the far end at 2,815 MB idle, about 197 MB per worker against Express's 65 MB — what a full TypeScript framework, ORM and template engine cost when each of 16 workers carries its own copy.

Two measurement methods, and the difference matters. The five native stacks are measured as PSS summed over their process group. The two Laravel stacks run in containers whose process trees are partly root-owned, so smaps_rollup is unreadable and a PSS sum silently skips those processes — they are measured from their cgroup (docker stats) instead. An earlier revision of this page reported Octane at 43 MB, which was the php artisan octane:start supervisor alone while the FrankenPHP process contributing 230 MB of RSS was skipped for being unreadable. cgroup usage and PSS are not the same metric, so treat the Laravel rows as comparable to each other and only indicative against the rest.

The code being measured

Each app is idiomatic for its framework — the DB action in each:

Soli

class PostsController < Controller
  def db_json
    render_json(Post.pluck(:id, :title, :views).all)
  end
end

Rails

class PostsController < ApplicationController
  def db_json
    render json: Post.pluck(:id, :title, :views)
      .map { |id, title, views| { id: id, title: title, views: views } }
  end
end

Soli — model, complete

class Post < Model
end

Rails — model + migration

class Post < ApplicationRecord
end

# db/migrate/..._create_posts.rb
class CreatePosts < ActiveRecord::Migration[8.1]
  def change
    create_table :posts do |t|
      t.string  :title
      t.integer :views
    end
  end
end

The controllers, views and routes are line-for-line equivalent; the visible difference is the migration. That is a trade, not a win: SoliDB is schemaless, so Soli needs no migration and gets no database-enforced schema — Rails guarantees the table's shape, Soli lets you persist anything. The Express version is a working app too, but its view engine, ORM, pooling and template compilation are hand-assembled — the ~40 lines it takes are the frameworkless tax the other two don't pay.

What these multiples do and don't mean

Trivial handlers measure fixed framework overhead, which is precisely where Rails is weakest — that is why the JSON and template rows show 5–12× and the DB rows show 4–5×. On a page dominated by real query work the multiple compresses toward the DB row, not the template row. The honest claim this page supports is: Soli's framework overhead is roughly a tenth of Rails' and its render path beats Express's, while database-bound routes are architecture-limited for everyone. Quote it that way.

Reproducing

Warm up first, then measure for a fixed duration — never a fixed request count:

oha -z 8s  -c 100 --no-tui --output-format quiet http://localhost:PORT/route   # warm-up
oha -z 30s -c 200 --no-tui --output-format json  http://localhost:PORT/route

Three checks before believing any result, including this one. Verify the status codes and payload bytes — load generators report a wall of 301s or 500s as 100% "success", and two stacks returning different payloads aren't running the same benchmark. Run 30 seconds or more — a fixed-count run against a fast server finishes before reaching steady state. And watch the load generator's own CPU — co-located, it takes cores from the server, so every absolute number on this page is a floor. When in doubt, trust the CPU/req column: it survives all three mistakes.