Elysia 2 vs Django 6.1, One Process Each: 14–21x per CPU-Second
We ran one seven-route API on Elysia 2 (Bun) and on Django 6.1 with Ninja, sync under Gunicorn and async under Uvicorn, one process each. On the four database routes at c=100 Elysia served 14.1–21.5x as many successful requests per CPU-second as sync Django. Async Django fell 12.6–16.2% behind sync at 500 connections, Django won the hash loop, and the runs that broke before this one are here too.
TL;DR: We ran one API three ways on one Apple M4 Pro laptop: Elysia 2.0.0-beta.12 on Bun 1.4.2, Django 6.1.1 + Django Ninja 1.7.0 under one Gunicorn gthread worker, and the same app under one Uvicorn worker with async views. Each was one process using a little over one core, so we lead with successful requests per CPU-second. On the four database routes at c=100 Elysia served 14.1–21.5x what sync Django did (13.0–21.7x across all concurrencies). Sync Django was saturated at 10 connections; more concurrency only added latency. Async Django beat sync by 46.2% on
/healthat c=10 and lost to it on every route at c=500, by 12.6–16.2%. On a pure SHA-256 loop Django was 2–6% ahead of Bun'snode:crypto. Per-route memory measured process history, so we make no memory claim. Earlier runs broke before the run of record, starting with a 2-second client timeout.
GET /users/:id runs one SELECT and returns one row. At 100 concurrent connections, Elysia 2 on Bun served 33,431.7 requests per second. Django 6.1.1 with Django Ninja under one Gunicorn worker served 1,707.5. That is 19.6x, and the first reply we expect under it is "you gave Django one worker".
We did, and Elysia got one process too. Both server process trees used a little over one core. Divide successful requests by CPU-seconds, window by window, and the medians read 29,832 against 1,389. That is 21.5x. On the other three database routes at c=100 the same metric gives 14.1x on /me, 15.1x on the user list and 18.1x on POST /orders.
The first full run behind those numbers never finished. On 2026-09-17 every Django window at c=500 failed its warm-up with errors, and nothing in Django was broken: bombardier drops a request after 2 seconds by default, and a saturated Django queue takes longer than that to answer. The load generator was counting waiting as failure.
This compares two complete stacks. The runtime, ORM, Postgres driver, HTTP server, validator and serializer all differ between the two sides, so none of these numbers is a verdict on Python as a language or on Django as a framework.
The seven routes, schema, indexes and seed match our Elysia 2 vs NestJS 12 benchmark. That was a different run on a different day, and its numbers stay out of every comparison below.
189 windows on one laptop
The machine is one Apple M4 Pro laptop with 14 cores on macOS 27.0. The API under test, PostgreSQL 17.10 from Homebrew and the load generator (bombardier, fasthttp client) all ran on it, with no CPU affinity and no cgroups.
E: Elysia 2.0.0-beta.12 on Bun 1.4.2, Drizzle ORM over Bun SQL, TypeBox schemas viat.Object, jose for JWT, a productionbun buildwithNODE_ENV=production.N-sync: Django 6.1.1 with Django Ninja 1.7.0 on CPython 3.14.6, Gunicorn 26.2.0 with--workers 1 --worker-class gthread --threads 10. Django ORM on psycopg 3.3.5 (binary) with a psycopg pool of min 1, max 10, andCONN_MAX_AGE=0, so connections return to the pool and no request opens its own TCP connection.N-async: the same app under Uvicorn 0.53.0,--workers 1 --loop uvloop --http httptools, with async views on Django's async ORM interface (async forover querysets,acreate).
Both Django modes run Pydantic 2.13.5, an orjson 3.12.0 renderer and PyJWT 2.14.0, with MIDDLEWARE = [], DEBUG=False and no request logging. We stripped Django down on purpose: each of those choices takes work off the Django request path. The reads use .values(*USER_FIELDS) in both modes, so no model instances get built for /me, /users/:id or the list.
The database holds 50,000 users and 200,000 orders. /health returns a new object. POST /echo validates a body (name, email, age, tags) and returns it. GET /me verifies an HS256 JWT and reads one user row. GET /users/:id is one SELECT, and GET /users?page=7&limit=20 is one SELECT with no COUNT. POST /orders is one validated INSERT … RETURNING. GET /cpu?n=15000 chains 15,000 SHA-256 hashes, each over the previous hex digest.
Before any load, a parity gate runs 38 contract checks per configuration: all seven happy paths, negative inputs, default pagination, JWT failures, unknown fields and whether an inserted row persists. Error bodies stay framework-native, so parity compares status codes and leaves the wording alone.
The protocol is 3 rounds × 3 configurations × 7 routes × concurrency 10, 100 and 500. Each window is a discarded 5-second warm-up followed by 30 measured seconds, and every throughput and latency figure in the tables is the median of three windows. The configuration order rotates each round, and each configuration starts a fresh process per round that serves all 21 route-and-concurrency windows in order, /health first and /cpu last. Postgres runs CHECKPOINT before every warm-up and every measured window, and the orders table is reset and VACUUM FULL ANALYZEd before each /orders window, outside the timer.
A sampler reads CPU once a second for every process except the server tree, the harness, bombardier and the fixture's Postgres backends; a window whose mean foreign CPU reaches 450% (100% is one core) is measured again. The run of record went from 21:00 to 23:14 local time (UTC+5) on 2026-09-18, harness v3: 189 of 189 windows valid, 0 contended attempts, 0 Postgres checkpoints inside a measured window, and zero non-2xx responses or transport errors in any cell.
Why we divide by CPU-seconds
A raw req/s ratio against one Gunicorn worker says little about a deployment that runs eight. So the harness takes the ps CPU-time delta of the whole server process tree during each window and divides successful req/s by the cores that tree used. The comment in our report.ts says the metric exists so the result "survives 'you only ran one worker'". The tree is the server alone: Postgres backends and bombardier are outside it on both sides.
It works here because every configuration sat near one core: median server-tree usage was 1.02–1.45 cores for E, 1.13–1.23 for N-sync on the API routes and 1.17–1.45 for N-async. The ten gthread threads match the pool's ten connections, and whatever the thread count, the per-CPU figure normalises for how much of a core each side used.
What the metric does not license is multiplication. We did not run eight workers, so "with eight workers Django would…" is a sentence this benchmark cannot finish. It does not turn into a server bill either.
The database routes carry the headline
Per CPU-second at c=100, E over N-sync: /me 18,731 against 1,331 (14.07x), /users/:id 29,832 against 1,389 (21.48x), the list 20,460 against 1,353 (15.12x), POST /orders 25,664 against 1,421 (18.06x). Across all three concurrencies the four database routes land between 13.01x and 21.68x.
Raw req/s tells a similar story because both sides burned about the same cores. At c=100: /me 26,013.5 against 1,611.1 (16.15x), /users/:id 33,431.7 against 1,707.5 (19.58x), the list 24,523.6 against 1,644.9 (14.91x), /orders 28,507.6 against 1,751.9 (16.27x). Over all concurrencies the raw DB-route ratio is 14.44–19.58x against N-sync and 13.55–22.64x against N-async.
Flip between per-CPU-second and raw req/s at c=100; the table view under the chart carries each side's cores:
GET /me 18,731 vs 1,331GET /users/:id 29,832 vs 1,389GET /users (list) 20,460 vs 1,353POST /orders 25,664 vs 1,421GET /health 135,097 vs 2,363POST /echo 113,399 vs 2,239table view
| route | E req/s | sync req/s | async req/s | raw × | E /CPU-s | sync /CPU-s | async /CPU-s | per-CPU × | cores E / sync / async |
|---|---|---|---|---|---|---|---|---|---|
| GET /me | 26,013.5 | 1,611.1 | 1,545.5 | 16.15x | 18,731 | 1,331 | 1,308 | 14.07x | 1.39 / 1.21 / 1.18 |
| GET /users/:id | 33,431.7 | 1,707.5 | 1,637.5 | 19.58x | 29,832 | 1,389 | 1,374 | 21.48x | 1.13 / 1.22 / 1.19 |
| GET /users (list) | 24,523.6 | 1,644.9 | 1,582.1 | 14.91x | 20,460 | 1,353 | 1,323 | 15.12x | 1.20 / 1.21 / 1.19 |
| POST /orders | 28,507.6 | 1,751.9 | 1,672.0 | 16.27x | 25,664 | 1,421 | 1,372 | 18.06x | 1.11 / 1.23 / 1.22 |
| GET /health | 138,992.4 | 2,706.6 | 3,198.7 | 51.35x | 135,097 | 2,363 | 2,656 | 57.18x | 1.03 / 1.15 / 1.20 |
| POST /echo | 115,798.4 | 2,548.9 | 2,996.5 | 45.43x | 113,399 | 2,239 | 2,510 | 50.65x | 1.02 / 1.14 / 1.19 |
The two routes without a database show the biggest ratios: /health 51.35x raw (138,992.4 against 2,706.6) and 57.18x per CPU-second, /echo 45.43x and 50.65x. We keep them out of the headline, because a route with no database behind it measures the HTTP server and the framework path alone, and few production endpoints look like that.
One sync worker is full at 10 connections
N-sync throughput barely moves with concurrency. /users/:id did 1,684.1 req/s at c=10, 1,707.5 at c=100 and 1,697.1 at c=500; /health did 2,647.6, 2,706.6 and 2,703.7. One Gunicorn worker with 10 threads was already saturated at the lowest concurrency we measured.
Every extra connection waits in line. N-sync's /users/:id p50 was 5.726 ms at c=10, 57.229 ms at c=100 and 287.276 ms at c=500, with a p99 of 466.785 ms at c=500. Elysia on the same route at c=500: p50 15.322 ms, p99 21.093 ms.
Throughput and median latency for N-sync /users/:id as connections rise, with Elysia's figures under each row:
table view
| c | sync req/s | sync p50 | sync p99 | E req/s | E p50 | E p99 |
|---|---|---|---|---|---|---|
| 10 | 1,684.1 | 5.726 ms | 10.0 ms | 32,187.5 | 0.293 ms | 0.799 ms |
| 100 | 1,707.5 | 57.2 ms | 88.7 ms | 33,431.7 | 2.825 ms | 4.875 ms |
| 500 | 1,697.1 | 287.3 ms | 466.8 ms | 32,398.7 | 15.3 ms | 21.1 ms |
Going from 100 to 500 connections bought sync Django nothing in req/s and a median five times longer.
Does async Django help?
At low concurrency it looks that way. At c=10, N-async beat N-sync by 46.2% on /health (3,871.7 against 2,647.6) and by 44.0% on /echo (3,588.9 against 2,492.4). On the four database routes at c=10 async was also ahead, by 5.9% to 6.8%. We do not claim that gap. N-async's own round-to-round spread reaches about 10%, and a 6% difference fits inside it.
At c=500 the sign flips on every route, and the windows no longer overlap: /health −14.5% (2,312.2 against 2,703.7), /echo −12.6%, /me −15.3% (1,362.0 against 1,608.3), /users/:id −15.7% (1,431.2 against 1,697.1), the list −15.9% (1,380.3 against 1,640.9), /orders −16.2% (1,458.8 against 1,739.8). The tail moved the same way: N-async's /users/:id p99 at c=500 was 656.783 ms against N-sync's 466.785 ms.
The per-route gap at both ends of the concurrency range:
GET /me+6.6% · unclaimedGET /users/:id+6.8% · unclaimedGET /users (list)+5.9% · unclaimedPOST /orders+6.7% · unclaimedGET /health+46.2%POST /echo+44.0%GET /me−15.3%GET /users/:id−15.7%GET /users (list)−15.9%POST /orders−16.2%GET /health−14.5%POST /echo−12.6%table view
| route | c=10 sync | c=10 async | Δ c=10 | c=500 sync | c=500 async | Δ c=500 |
|---|---|---|---|---|---|---|
| GET /me | 1,591.2 | 1,696.6 | +6.6% (unclaimed) | 1,608.3 | 1,362.0 | −15.3% |
| GET /users/:id | 1,684.1 | 1,798.4 | +6.8% (unclaimed) | 1,697.1 | 1,431.2 | −15.7% |
| GET /users (list) | 1,624.8 | 1,720.6 | +5.9% (unclaimed) | 1,640.9 | 1,380.3 | −15.9% |
| POST /orders | 1,737.3 | 1,854.3 | +6.7% (unclaimed) | 1,739.8 | 1,458.8 | −16.2% |
| GET /health | 2,647.6 | 3,871.7 | +46.2% | 2,703.7 | 2,312.2 | −14.5% |
| POST /echo | 2,492.4 | 3,588.9 | +44.0% | 2,544.0 | 2,223.4 | −12.6% |
/health shows the shape most cleanly. N-async served 3,871.7, then 3,198.7, then 2,312.2 req/s at c=10, 100 and 500, while its tree RSS climbed from 70.70 to 94.77 to 160.34 MiB and its cores from 1.19 to 1.20 to 1.45. N-sync on the same route stayed flat.
c=500 always runs after c=10 and c=100 in the same process, so the drop could be process age. The next window argues against that: /echo at c=10 runs right after /health at c=500 in the same async process, and it came back at 3,588.9 req/s.
A thread per request, on every route
We read the installed library source. In Django 6.1.1, QuerySet.__aiter__ runs await sync_to_async(self._fetch_all)() (django/db/models/query.py:476-480), and acreate is await sync_to_async(self.create)(**kwargs) (:758-759). The async ORM interface wraps the sync ORM.
asgiref 3.12.1 keeps a process-wide single_thread_executor (asgiref/sync.py:409), but only as the last fallback (:488). Django's ASGI handler wraps every request in async with ThreadSensitiveContext(): (django/core/handlers/asgi.py:172), and inside that context sync_to_async creates a one-thread executor for that request alone (asgiref/sync.py:467-478), shut down when the request ends. At c=500 that can mean hundreds of short-lived threads under one GIL, sharing a 10-connection pool. The sync worker has exactly 10 threads asking for 10 connections.
Routes with no database pay too. The handler sends request_started through asend (asgi.py:188), which wraps its sync receivers in sync_to_async, and closes every response with await sync_to_async(response.close)() (:218). Even /health makes several thread hops per request. That is consistent with all six routes losing at c=500 and with async's CPU and RSS growing under load; we did not profile it, so it stays a hypothesis. It also says nothing about Django's async support in general. A view that calls psycopg's async interface directly skips this path, and we did not build one.
/cpu belongs in its own box
GET /cpu?n=15000 never enters the ratios above, because the two sides call different crypto bindings. Django came out ahead. E, with node:crypto createHash, served 212.6, 211.7 and 209.2 req/s at c=10, 100 and 500. N-sync with hashlib served 217.0, 222.1 and 221.3; N-async 222.2, 224.0 and 221.6. That is Django ahead by roughly 2–6% in raw req/s (E over N-sync 0.98x, 0.95x, 0.95x). E burned 1.17 cores to Django's 1.01, so on our lead metric the gap is wider: 0.85x, 0.82x and 0.81x per CPU-second.
E stays on node:crypto in the main matrix to match the Nest benchmark. In a separate run right after the main one (23:15 to 23:28 local), we put E against E-native, the same chained-hex algorithm on Bun.SHA256.hash (parity passed), each alone on /cpu with a fresh process per round. E did 204.4, 208.3 and 209.1 req/s; E-native did 526.7, 528.7 and 528.6, which is 2.54x at c=100 on 1.01 cores against E's 1.18–1.22. We suspect allocation and GC from a new Hash object per iteration, unprofiled.
At c=500 N-sync's p99 was 31,986.488 ms and N-async's 31,933.319 ms: some requests waited about 32 seconds. E's p99 was 2,497.516 ms. The medians point the other way, with N-sync at 1,094.858 ms, N-async at 541.103 ms and E at 2,392.994 ms. Elysia served everyone at about the same slow speed; Django served most requests sooner and left some for half a minute.
The memory number we will not give you
A per-route RSS chart would be easy to draw from this data, and wrong. RSS here is the mean for the whole server tree (Gunicorn master and worker summed, so shared pages may count twice), and one process serves all 21 windows of a round in order.
E's round-1 process went 65 MiB on /health, 80 on /echo, 106–136 on /me, 137–154 on /users/:id, 157–161 on the list, 164 on /orders and 165 on /cpu, and never came back down within the round. N-sync went 84–86 on /health, 88 on /echo, 100–102 on /me, then held at 103 MiB for every later route. N-async read 126–171 after /health.
The separate /cpu run shows how much of that is history. There E served only /cpu from a fresh process and sat at 71.01, 73.98 and 76.94 MiB. In the main run, same code and same route, it read 164.55. (E-native in that separate run sat at 136.08–144.43 MiB, and we have no explanation for it.)
Two statements survive. On /health at c=100, the first route each process served, E sat at 65.77 MiB, N-sync at 84.92 and N-async at 94.77. By the end of a round E's single process had grown to about 165 MiB and N-sync's to about 103. Neither supports a claim that one stack uses less memory than the other, and we make neither.
Where the benchmark tried to lie
A 2-second timeout that turned queues into errors
The 2026-09-17 run from the opening (results/full-20260917T005031Z.json) failed warm-up on every Django window at c=500, on async Django's /cpu at c=100 and on some of Elysia's /cpu and list windows at c=500, with non-2xx responses and transport errors. The warm-up fails closed, so each of those windows was marked invalid, and we killed the run. Two limits stacked. Bombardier gives up on a request after 2 seconds by default, and a saturated single process answers slower than that: every Django route at c=500, and Elysia's /cpu too. macOS kern.ipc.somaxconn at 128 dropped connections from the listen backlog on top of it.
Every bombardier call now passes -t 180s, and sudo sysctl -w kern.ipc.somaxconn=4096 raises the backlog (it resets on reboot, so the harness records it). A slow response now counts as latency, which is why the /cpu section can report a 31,986 ms p99 instead of an error rate.
A closed lid, another session, then Spotlight
Two attempts died from outside: the laptop lid closed and the machine slept, and another agent session killed our process tree. Harness v2 added the foreign-CPU gate, sleep detection (any wall-clock gap over 10 seconds) and resume from the last valid window, and runs now start under caffeinate -dimsu.
Run v2 (2026-09-18, 09:29 to 12:18 local) finished 189 of 189 valid after re-measuring 8 contended attempts. The culprits, in order: Spotlight indexing, Chrome and Arc, a bun script from another worktree session, a git-diff tool scanning worktrees, and Raycast. The longest stall was about 15 minutes, on E /health c=500 in round 3. Spotlight is now off for good (sudo mdutil -a -i off).
/orders went from 18.1k to 27.6k
In v2, Elysia's POST /orders fell at c=500 in every round: 18.1k, 20.3k and 14.5k req/s, against 26.0k, 26.6k and 19.8k at c=100, for a c=500 median of 18,136.2. v2 did not record checkpoints per window. The Postgres server log (log_checkpoints=on) did: five WAL-triggered checkpoints during v2, each starting within about 8 seconds of an E /orders window. Four of the five line up with the low windows; the fifth, round 2 at c=100, came back at 26,589, which is not low. That is a timing correlation, not a per-window measurement.
v3 runs CHECKPOINT before every window and records any checkpoint inside one. The c=500 median came back at 27,561.7, from windows of 27,562, 20,193 and 27,593: round 2 still dipped to 20.2k with zero checkpoints and no contention, and we do not know why. The median is unaffected, and the cell carries a 26.8% window spread against 0.5–2.7% for every other E API cell.
Every E /orders window from v2 and v3, with the five checkpoint-aligned windows and the unexplained dip marked:
table view
| run | c | round | req/s | note |
|---|---|---|---|---|
| v2 | 100 | 1 | 25,976 | |
| v2 | 100 | 2 | 26,589 | WAL checkpoint |
| v2 | 100 | 3 | 19,795 | WAL checkpoint |
| v2 | 500 | 1 | 18,136 | WAL checkpoint |
| v2 | 500 | 2 | 20,268 | WAL checkpoint |
| v2 | 500 | 3 | 14,545 | WAL checkpoint |
| v3 | 100 | 1 | 28,634 | |
| v3 | 100 | 2 | 28,475 | |
| v3 | 100 | 3 | 28,508 | |
| v3 | 500 | 1 | 27,562 | |
| v3 | 500 | 2 | 20,193 | cause unknown |
| v3 | 500 | 3 | 27,593 |
Round 1 ran on a busier machine
Foreign CPU during E's round-1 API cells read 140–238% (median about 200%), against 97–155% in rounds 2 and 3, all under the 450% gate. Several Django cells took their lowest window in round 1; N-sync /me at c=10 read 1,413 against 1,591 and 1,611, the run's worst N-sync spread at 12.4%. Medians of three absorb one low window. Most N-sync cells spread 1.6–4.6% and N-async's 0.7–10.2%, and there is no formal noise floor for this run, which is why the c=10 async gap on the database routes stays unclaimed.
What we did not measure
- Granian, in place of Gunicorn or Uvicorn.
- More than one worker. Per CPU-second answers the one-worker objection for efficiency on this machine; it does not tell you where a multi-worker deployment saturates.
- A multi-core, equal-resource Linux lane with the load generator on its own box.
- DRF. The Django side is Ninja with Pydantic.
- Native async SQL, for example psycopg's async API called straight from the views, which would test the per-request thread path above.
- Free-threaded CPython 3.14t.
- Elysia stable 1.4.x, and Elysia's AOT build in the full matrix.
The harness is in the repository. If you run one of these, we want the numbers. The noise-floor and boot runs exist only as quick versions, so this article has no boot figures.
Where ShipKit fits
ShipKit's API runs Elysia 1.4.16. The version measured here is the 2.0.0-beta.12 beta, and nothing above changes what ShipKit ships today.
Running it yourself
Before you trust any c=500 number on macOS, pass -t 180s to bombardier and raise kern.ipc.somaxconn to 4096; with the defaults, every Django window at c=500 in our first run failed warm-up. Run CHECKPOINT before every measured window of an insert benchmark. Then divide successful req/s by the server tree's CPU cores before comparing stacks: on this laptop that turns 19.58x on /users/:id into 21.48x. The medians are in results/full-2026-09-18b-summary.md, all 189 windows in results/full-2026-09-18b.json.
Sources
- Benchmark repository with apps, harness, parity checks and raw results: https://github.com/Dave93/elysia-vs-django
- The previous article, same seven routes and seed, different run: https://shipkit.davrapps.dev/en/blog/elysia-2-vs-nestjs-12-what-each-layer-buys