Back to blog

Elysia vs Express: 1.2–2.2× on Bun, 2.1–3.0× vs Node

Davron14 min read
BunElysiaexpress

Elysia on Bun served 2.09× to 3.00× what Express on Node did on six of seven routes. On the same runtime and driver, Elysia was worth 1.19× to 2.24×; Bun and Bun SQL did the rest.

Elysia vs Express, measured on one Linux desktop: on six of our seven routes, Elysia 2.0.0-rc.4 on Bun served 2.09× to 3.00× the requests per second of Express 5.3.0 on Node, and the seventh, a pure hashing loop, was a tie. Not all of that is Elysia. Swapping Express for Elysia on the same runtime and Postgres driver was worth 1.19× to 2.24× on those six; Bun and its built-in SQL client did the rest. We're the team behind ShipKit, a starter kit on Elysia and Bun. This is for teams on Express, Node and Postgres who are choosing between Bun, Elysia or both.

We split the move into three steps, one layer each. The same Express file started with bun instead of node: 1.19× to 1.47× on every route but /cpu. Bun SQL in place of postgres.js, still under Express: 1.12× to 1.59× on the four routes that query Postgres. Then Elysia in place of Express. Each step won all three rounds on every row in those ranges, and /cpu didn't budge: all four configurations served 52 to 55 requests per second.

Four configurations, three one-variable steps

We ran four configurations of one seven-route API, each a single process with NODE_ENV=production, and changed one layer at a time: the runtime, then the Postgres driver, then the framework. The runtime step runs one JavaScript file under node and then under bun, so it is Express moving to Bun with no code change.

config framework runtime Postgres driver file it runs
X-node Express 5.3.0 Node v26.10.0 postgres.js 3.4.9 apps/express-api/dist-node/index.js
X-bun-pg Express 5.3.0 Bun 1.4.2 postgres.js 3.4.9 the same file
X-bun Express 5.3.0 Bun 1.4.2 Bun SQL apps/express-api/dist/index.js
E Elysia 2.0.0-rc.4 Bun 1.4.2 Bun SQL apps/elysia-api/dist/index.js

All four reach Postgres through Drizzle ORM 0.45.2 with a pool of 10. One footnote, on the driver step: X-bun runs the Bun-target bundle and X-bun-pg the Node-target bundle of the same source. Our Elysia 2 vs NestJS 12 write-up split a migration into runtime and framework steps the same way; this time the driver gets a step of its own.

Every configuration serves the same seven routes: /health, POST /echo (a validated four-field body), /me (an HS256 JWT check plus one read), /users/:id, a 20-row list, POST /orders (a validated insert) and /cpu, which runs 15,000 chained SHA-256 hashes through node:crypto. Postgres holds 50,000 users and 200,000 orders.

Express runs as express() ships it, with express.json() mounted globally and the weak ETag and X-Powered-By header left on.

For validation, Express gets Elysia's own stack, compiled typebox 1.4.6 and exact-mirror 1.2.7, through a small middleware of ours (tb in apps/express-api/src/app.ts), so the validator itself is held constant. Only path and query coercion differs; more on that below.

Elysia moved under us during setup. On 2026-11-08 npm's latest tag switched to a release candidate, 2.0.0-rc.4, while our benchmark still pinned 2.0.0-beta.12, the version our earlier articles measured. We stopped the launch during its preflight and moved the Elysia app to the release candidate. Bun kept the older typebox and exact-mirror versions from the lockfile until we pinned the two above, and the Express app got the same pair.

Before any measurement, a 38-check parity gate covered exact bodies on every happy path, 404 and 401 paths, five JWT failure modes, 422s on bad input, unknown-key stripping and an inserted order read back. All four passed 38 of 38.

We leave developer experience and migration effort out of this article; the run measured neither.

Elysia on Bun vs Express on Node, end to end

At 100 connections, Elysia on Bun served 2.73× Express on Node on /health, 2.82× on /echo and 2.29× to 2.57× on the four database routes. Across 10, 100 and 500 connections those six routes ranged from 2.09× to 3.00×, with Elysia ahead in every round. On /cpu the two tied.

route X-node X-bun-pg X-bun E
GET /health 21,051 29,410 29,986 57,546
POST /echo 15,322 19,474 19,219 43,137
GET /me 3,574 5,049 7,680 9,172
GET /users/:id 4,196 5,647 7,975 10,683
GET /users (list) 3,411 4,069 6,084 7,811
POST /orders 3,498 4,231 6,296 8,544
GET /cpu 54 53 52 54

Median successful requests per second of three 30-second windows at c=100, in step order from left to right. Across all three concurrencies the range runs from /orders at c=10 (2.09×) to /echo at c=500 (3.00×).

The tie on /cpu held at every concurrency: each configuration served 52 to 55 requests per second, the overall ratio read 0.99×, 1.00× and 1.00×, and neither side won all three rounds.

Where the 2.09× to 3.00× comes from

The runtime, driver and framework ratios multiply into the overall one, give or take rounding. At 100 and 500 connections the driver step was the largest of the three on all four database routes. On /health and /echo, which never touch Postgres, the framework step was the largest. The runtime step paid on every route except /cpu.

route runtime: Node → Bun driver: postgres.js → Bun SQL framework: Express → Elysia overall
GET /health 1.40× (3/3) 1.02× (2/3) 1.92× (3/3) 2.73× (3/3)
POST /echo 1.27× (3/3) 0.99× (2/3) 2.24× (3/3) 2.82× (3/3)
GET /me 1.41× (3/3) 1.52× (3/3) 1.19× (3/3) 2.57× (3/3)
GET /users/:id * 1.35× (3/3) 1.41× (3/3) 1.34× (3/3) 2.55× (3/3)
GET /users (list) * 1.19× (3/3) 1.49× (3/3) 1.28× (3/3) 2.29× (3/3)
POST /orders 1.21× (3/3) 1.49× (3/3) 1.36× (3/3) 2.44× (3/3)
GET /cpu * 0.97× (2/3) 0.99× (3/3) 1.04× (3/3) 1.00× (2/3)

Ratio of medians at c=100; in parentheses, the rounds the faster side won. * The Express side includes our own coercion code (next section).

step routes, each at c=10, 100 and 500 range rows won 3/3
runtime all but /cpu 1.19× to 1.47× 18 of 18
driver the four database routes 1.12× to 1.59× 12 of 12
framework all but /cpu * 1.19× to 2.24× 18 of 18
overall all but /cpu 2.09× to 3.00× 18 of 18

The same steps across all three concurrencies; the last column counts rows where the faster side won all three rounds.

The driver step grew with load on the database routes: 1.12× to 1.26× at c=10, then 1.41× to 1.59× at c=100 and c=500, ahead of both other steps on all eight of those rows.

Bun SQL is Bun's built-in client, so this step needs Bun. In our code it touches one database module and no route handler.

As for the bundle footnote: on /health, /echo and /cpu, which never talk to Postgres, the driver step read 0.99× to 1.02×, so the Bun-target and Node-target bundles stayed within 2.0% of each other there.

What Elysia adds on the same runtime

With Bun and Bun SQL fixed, Elysia served 1.19× to 2.24× what Express served on the six routes besides /cpu, ahead in every round. The step covers everything Express brings to a request: its router, express.json(), res.json() with the default ETag and X-Powered-By header, Bun's node:http layer underneath (Elysia uses Bun.serve), and on three routes our own coercion code.

Three routes come with a caveat. /users/:id, the list and /cpu read numbers from the path or query string, which arrive as strings. Under Express those take our middleware's slow path: a guard copying Elysia's coercion rules, plus TypeBox's interpreted Default and Convert. Elysia compiles coercion. So part of the framework step on those rows is our code, by an amount we did not measure. Our Elysia 2 vs Hono 4.13 comparison, on the same routes and parity gate, carried the same caveat.

/health, /echo, /me and /orders carry no such caveat: /health and /me validate nothing, and the /echo and /orders bodies pass the compiled check on the fast path. There the step ran from 1.19× on /me to 2.24× on /echo, where Elysia served 43,137 requests per second at c=100 to Express's 19,219.

We left Express's defaults alone. In 5.3.0, lib/application.js turns on X-Powered-By and weak ETags, and res.json() goes through res.send(), which hashes the body with SHA-1 to build that ETag. Both sit inside the framework step. We never ran Express with them off, so we have no number for their cost.

Latency, CPU and memory

At c=100, Elysia's p99 on the four database routes was 13.81 to 20.61 ms, against 38.64 to 47.36 ms for Express on Node; Express on Bun with Bun SQL sat at 18.69 to 26.22 ms. Median resident memory per cell ran from 54 to 88 MiB for Elysia and from 95 to 242 MiB for Express on Node.

Memory needs a caveat. Each configuration ran as one fresh process per round and served all 21 cells in a fixed order, so a later row's memory includes the process's history. The first measured window of every round's process, /health at c=10, read 55 MiB for Elysia, 79 and 80 MiB for Express on Bun, and 95 MiB for Express on Node.

/cpu breaks the latency pattern. At the same 52 to 55 requests per second, Express on Node had a p50 of 0.76 s and a p99 of 31.5 s at c=100, and every round showed that shape (bombardier waits for requests still in flight when the 30 seconds end, so those windows ran 31.8 to 32.1 s). The three Bun configurations' medians sat between 1.83 and 1.99 s at both percentiles, with no Bun round above 2.28 s at p99. We observed the shape and have not explained it.

Node also did that work on 1.00 core, at 53 to 55 requests per CPU-second, against 1.16 to 1.19 cores and 44 to 46 on Bun.

How far to trust these numbers

Every step we call a result beat the widest round-to-round spread, 6.6%, and won all three rounds. Five back-to-back 30-second windows on Elysia at c=100 spread 1.0% on /health and 1.7% on /users/:id, and all 252 measured windows in the run of record were valid.

Load came from bombardier 2.0.2, holding keep-alive connections at c=10, 100 and 500, with a 5-second warmup and a 30-second window per cell. Each of the three rounds rotated the configuration order and started every server fresh. Only 2xx responses count, and one non-2xx response or transport error invalidates a window. Postgres ran CHECKPOINT before every warmup and window, and the orders table went back to the seed before every /orders window.

A foreign-CPU gate summed every other process's CPU once a second and re-measured any window averaging 200% or more. Averaged per window, foreign CPU had a median of 101% and a maximum of 147%; one-second samples reached 349%. Two contended attempts were re-measured. No Postgres checkpoint landed inside a measured window.

One window failed. It was Express on Bun with Bun SQL, /cpu at c=500, round 1: the server closed 36 connections before the first response byte. We haven't established why, and the failed attempt stays in the repository. Rounds 2 and 3 of that cell were clean, as was the re-measured window at 52.0 requests per second.

What this run leaves out

Everything ran on one Intel Core i3-10100 desktop with 4 cores and 8 threads, with bombardier and Postgres on the same host, so the load generator competed with the server for the same 8 logical CPUs. Each server was a single process with no cluster mode, using 0.97 to 1.30 cores.

  • The governor stayed on powersave, and PostgreSQL 17.11 ran in Docker (postgres:17-alpine, host networking). Bun 1.4.2 and Node v26.10.0 were the versions already on the host.
  • Zod, Joi and express-validator were not measured, and neither was Express with ETag and X-Powered-By turned off.
  • Express 4, node-postgres, Prisma and every ORM other than Drizzle are outside the run.
  • None of these numbers compare with our Hono, NestJS or Django articles, which ran on an Apple M4 Pro laptop.

Bun, Elysia, or both?

Elysia beat Express on the same runtime and driver by 1.19× to 2.24×, and Bun with Bun SQL supplied the rest of the 2.09× to 3.00× move. Express ran on Bun unchanged and passed every parity check, so a team can take the runtime step first and measure it against its own traffic before touching a route.

The largest step followed what each route spends its time on. /health and /echo, which answer without touching Postgres, gained most from the framework; routes that wait on Postgres gained most from the driver once 100 or more connections were open. Hashing in node:crypto didn't move past the round-to-round spread under any step.

Each stage won every round on the routes it covers, so a staged move gives you a number to check after every step.

If most of your traffic waits on Postgres and your queries go through Drizzle, start with Express on Bun and Bun SQL, and load-test it at your own peak: it is the configuration behind the run's one failed window, on /cpu at c=500. Moving to Bun SQL from Prisma, node-postgres or another ORM is a data-layer change this run did not measure.

Running it yourself

The apps, benchmark scripts, parity checks and raw results live at github.com/Dave93/elysia-benchmarks. The run of record is results/full-express-2026-11-09-resumed.json, and results/express-facts.md, generated from it, is the fact set behind this article. The commands below assume Linux, with Docker, Bun, Node, bombardier, pg_isready and lsof on the PATH.

docker run -d --name elysia-bench-pg17 --network host --shm-size=256m \
  -e POSTGRES_HOST_AUTH_METHOD=trust -v elysia-bench-pg17:/var/lib/postgresql/data \
  postgres:17-alpine -c port=5433 -c listen_addresses=127.0.0.1
export DATABASE_URL=postgres://postgres@127.0.0.1:5433/bench_elysia_django
bun install --frozen-lockfile
bun install --cwd apps/elysia-api --frozen-lockfile
bun install --cwd apps/express-api --frozen-lockfile
bun run seed
(cd apps/elysia-api && bun run build) && (cd apps/express-api && bun run build)
scripts/run-express.sh --check   # preconditions only
scripts/run-express.sh           # about 3 hours

FAQ

Is Elysia faster than Express?

Yes, on six of our seven routes and in every round. On Bun 1.4.2 with Bun SQL under both, Elysia 2.0.0-rc.4 served 1.19× to 2.24× the requests per second of Express 5.3.0; against Express on Node v26.10.0, 2.09× to 3.00×. On /cpu the gap stayed inside the round-to-round spread.

Does Express run on Bun?

Yes. Express 5.3.0 ran on Bun 1.4.2 through Bun's node:http layer in two of our four configurations, and both passed the same 38 parity checks as Elysia. One of 252 windows failed on Express on Bun with Bun SQL, on /cpu at c=500, a cell whose median latency was 9.5 s: 36 connections closed before the first response byte. It re-measured clean. We haven't found the cause.

Is Express faster on Bun than on Node?

On six of seven routes, yes: 1.19× to 1.47× with the same file, winning every round. On /cpu Node was ahead by 1.8% to 5.5%, inside the round-to-round spread, and spent fewer CPU-seconds per request. Bun SQL in place of postgres.js added 1.12× to 1.59× on the database routes.

Should you move an Express app to Elysia?

Our numbers cover seven routes on one machine; migration effort, Zod or Joi, clustering and Express 4 sit outside them. The framework step was worth 1.87× to 2.24× on /health and /echo and 1.19× to 1.39× on /me and /orders. On /users/:id and the list it read 1.28× to 1.40×, partly our own coercion code. If most of your routes wait on Postgres and use Drizzle, try Express on Bun with Bun SQL first: on our database routes both steps won every round without a route handler changing. That configuration also produced the run's one failed window.

Disclosure

Our team sells ShipKit ($149 one-time), a starter kit built on Next.js and Elysia on Bun. It uses Elysia 1.4.16, not the 2.0.0-rc.4 measured here, and plays no part in these runs.

Sources

  • Benchmark repository: github.com/Dave93/elysia-benchmarks, in particular apps/express-api/src/app.ts, bench/parity.ts, results/express-facts.md, results/full-express-2026-11-09-resumed.json and results/friction-log.md.
  • Express 5.3.0 as installed from npm: lib/application.js lines 94–97 (X-Powered-By, weak ETag, simple query parser), lib/response.js lines 161–191 (ETag in res.send); etag 1.8.1, index.js lines 47–49 (SHA-1, base64).
  • npm registry, read 2026-11-09: elysia latest is 2.0.0-rc.4, published 2026-11-08; express latest is 5.3.0, published 2026-10-09.