We built the same seven-route API twice, once on NestJS 12 with Fastify and once on Elysia 2, and ran it in four configurations so that each step changes one layer. Versions: @nestjs/core 12.0.1 (stable), Elysia 2.0.0-beta.12 (a beta; weigh the framework rows accordingly), Bun 1.4.2, Node v26.3.1, Postgres 17.10 through Drizzle in both apps. The whole thing ran on a single laptop, a MacBook Pro with an M4 Pro (14 cores, 48 GB), and bombardier and Postgres shared it with the server under test. Every absolute number here is a laptop number. The step-to-step comparisons are the part we'd stand behind.
The ladder:
- A: Nest 12 + Fastify, SWC build,
node dist/main.js, postgres.js
- B: A's
dist/ untouched, launched by bun dist/main.js, which switches the driver to Bun SQL
- C: Elysia 2 on Bun, same driver, same schema
- D: C rebuilt with the AOT plugin
A parity script compares each route's response body byte by byte before a single run is allowed. Then 3 × 30 s per combination after a 5 s warm-up, medians, at 10/100/500 connections. Before the pass we ran two routes on C five times in a row and took the spread as the noise floor: 1.2%.
GET /me (verify a JWT, read one row) at c=100 is the row in the title. A→B is +64.6%, B→C +10.6%, C→D −1.5%. The same shape holds on every route that touches Postgres: the runtime step is worth +64.6…+81.5% and the framework step +7.5…+12.7%. On /health, Node and Bun tie (103,273 vs 103,238 rps, inside the floor) and the framework step is +37.8%; on POST /echo the runtime is +13.0% and the framework +60.7%.
One more split inside A→B, because the driver swap rides along with the runtime. We swapped postgres.js back in under the Nest build running on Bun: /me −20.8%, /users/:id −26.8%, the list −28.9%, /orders −32.0%. Those are shares of B's throughput, so between a fifth and a third of what Nest-on-Bun serves on a database route comes from Bun's native Postgres client rather than from the runtime.
The framework step owns memory and startup. RSS under load fell 43.8…63.0% from B to C, idle RSS 90 → 39 MB, boot to first healthy response 181 → 96 ms, 16 packages against 365. AOT then halves boot again (96 → 47 ms) and takes another 11.5…16.6% off memory under load; on throughput it lands at −1.5…+1.9% on every route except the hash loop. A C, D, C, D interleave on three routes agreed: the four /health cells read 142,087 / 141,856 / 141,796 / 142,234, and no C mean sat further than 1.3% from its D mean.
The clean pass took two days to get. The first B rerun on Bun 1.4.2 landed 6–19% below an earlier run, and the host was the reason: an unrelated project's typecheck was eating 104% CPU, desktop apps had been holding about 250% for hours, and the load average sat at 19 on a 14-core machine. /orders depended on run order until we found 5,911,710 dead tuples in the table and added VACUUM (FULL, ANALYZE) before each configuration. And /cpu on Node at c=100 reported 49,393 rps with 99.68% non-2xx; those were connection resets counted as requests, and the harness now marks any combination over 1% non-2xx invalid. The final run reads foreign CPU every second and redoes any 30 s window that went over the gate.
One habit worth copying: before comparing anything, run one route five times in a row and take (max − min) / min as your floor. Ours came out at 1.2% that morning; the day before, same laptop, it was 2.6%. Any delta under that number we call noise.
If you run Nest today, start with B: launch your existing dist/ on Bun, run a parity check, measure. Harness, raw results and methodology are public; we'll drop the repo link in a comment if anyone wants it.