Back to blog

Bun Owns the Speed, Elysia Owns the Memory: What We Learned Upgrading Every Layer at Once

Davron8 min read
BunElysiaPerformanceProduction

We upgraded Bun, Elysia 2 beta, Next 16.3 and TS 7 on a live ecommerce+POS API and benchmarked each layer alone. The credit does not go where you'd guess.

TL;DR: We upgraded four layers of a live ecommerce and POS API in roughly one working day (Elysia 1.4.28 to 2.0.0-beta, Bun 1.3.14 to a 1.4.0 canary, Next.js 16.3, TypeScript 7) and benchmarked each layer in isolation. On the dev bench, Bun alone bought +84.4% throughput on the DB-backed endpoint; Elysia 2's health delta sat inside the ±10% noise floor (DB +13.4%, borderline), but it cut dev-bench idle RSS 85%, from 1,357 MB to 202 MB. Elysia's opt-in AOT bundle then halved boot time (−52%) and took another 25% off production RSS. The migration itself cost a full day, much of it in the 5% the codemod missed.

In early August we took a production ecommerce and POS monorepo (227 route definitions across 44 controller files, serving a live pilot customer) and upgraded four layers of its stack: Elysia 1.4.28 to 2.0.0-beta.1 (beta.2 the next morning), Next.js 16.2.2 to 16.3.0, TypeScript 5.9 to 7.0.2, and Bun 1.3.14 to a 1.4.0 canary. Then we deployed it, added Elysia's new AOT compiled bundle, and removed a publicly exposed Swagger UI while we were at it. This writeup comes from ShipKit, a SaaS boilerplate distilled from production codebases like this one — the ecommerce platform here is one of them, and ShipKit itself ships Elysia 1.4 today, not the beta.

The interesting question was never whether it got faster. Stacked upgrades always produce a number someone likes. The question was which layer deserves the credit, because that decides what you should upgrade on your own stack, in what order, and what you can skip. So we benchmarked in isolation. Three configs, same machine, same script.

How honest are these numbers?

They are dev-server numbers with a measured ±10% noise floor: useful for attribution between configs on the same harness, and for nothing else. Do not read the absolute values as production throughput.

Local benchmarks ran on an Apple M4 Pro (14 cores) against the dev server (bun run --watch): 50 connections, 20-second runs, 6-second warm-up, RSS and CPU sampled every 0.5s, three runs per config (two for Config B). Two endpoints: /api/health for pure framework overhead, and a DB-backed sync endpoint returning a 6,197-byte payload for something closer to real work.

Before trusting anything, we measured run-to-run variance: about ±10%. That is the significance floor. Any delta inside it gets called noise below, including deltas that would have made better headlines.

Config health req/s (avg) DB req/s (avg) idle RSS
A: Elysia 1.4.28 + Bun 1.3.14 53,248 2,183 1,644 MB
B: Elysia 1.4.28 + Bun 1.4.0 canary 63,698 4,026 1,357 MB
C: Elysia 2.0.0-beta.1 + Bun canary 65,236 4,567 202 MB

Which layer deserves the credit?

Bun owns the speed and Elysia owns the memory: the runtime canary bought the throughput, the framework beta bought the RAM back.

Bun alone (A → B): +19.6% on health, +84.4% on the DB endpoint, idle RSS down 17.4%. The runtime canary nearly doubled throughput on the endpoint that does actual database work, with zero code changes.

Elysia 2 alone (B → C): health +2.4%, inside the ±10% noise floor, indistinguishable from zero. DB +13.4%, probably real but borderline. And idle RSS fell from 1,357 MB to 202 MB. That is −85%, and it is not noise of any kind.

Combined (A → C): +109% DB throughput, −88% idle RSS. CPU cost per 1,000 req/s on the DB endpoint dropped from 41.2 to 23.9.

Did Elysia predict its own result?

Yes. The 2.0 announcement states plainly that you shouldn't expect significantly faster throughput; the gains are memory and startup. Our measurements agree with their framing, which is worth something on its own: a framework release whose marketing matches independent measurement is rarer than it should be.

Was beta.2 any faster than beta.1?

No. One more isolation, run the next morning: beta.2 versus beta.1 came in within the noise floor on throughput, with load RSS slightly better. The changelog promised an AoT improvement; we could not see it at runtime.

What does AOT compilation buy on top?

Half the boot time and about a quarter of the memory, with throughput unchanged.

After the deploy we noticed our benchmarks had never used Elysia 2's opt-in AOT bundler plugin, and prod was running TypeScript directly. So we built it, on a separate harness this time: production start without --watch, three runs each.

  • Boot to healthy: 1.00s → 0.48s (−52%)
  • Idle RSS dropped from 200 MB to 145 MB, a 27.5% cut
  • Throughput: unchanged (health −0.3%, DB −7.1%, both called noise)

On the production box the pattern held: API RSS went from 204 MB to 152 MB (−25%) after the AOT deploy. Two gotchas. The build must point at the module exporting the Elysia instance, not the .listen() entry. And the build script needs process.exit(0), because importing the app starts timers and Redis connections.

What single production number should you quote?

20,679 req/s. That is what the deployed API, on its own box, handled on /api/health under wrk, at 2.36 ms average latency and 21% CPU. Different machine and tool from the local runs; never compare them.

What did the migration cost?

The benchmark table is the pleasant half. Getting Config C to exist took a full day. The official codemod claims to automate 95% of the migration, and it did handle the big mechanical change: 633 argument swaps across 112 files for the new route signature. The missing 5% was expensive:

  1. It invented a package. The @elysiajs/* scope moved to @elysia/*, and the codemod rewrote our swagger import to @elysia/swagger, which does not exist (it's @elysia/openapi). That alone made 339 of 381 tests fail at module load.
  2. Macros now see only framework primitives. No decorated values, no instance-derived state. Our RBAC macro read cache as undefined, roles never loaded, and every cookie-session route returned 403. Fix: a standalone resolveTenantServices(host) over module-level memos. This took the suite from 191 passing to 412.
  3. WebSocket is opt-in. The codemod inserted .use(websocket()) but not the import. Separately, ws.data is now the shared handler definition, so per-connection state moved to a WeakMap keyed by socket.
  4. Duplicate Elysia copies break private fields. "Cannot access invalid private field" means two copies of Elysia in the tree. We hit 5 copies at worst; an unused dependency whose peer range could never match a prerelease had to go.
  5. RFC 9457 error bodies. Unhandled errors now return Problem Details instead of {message, summary}. Our admin had 13 call sites that would toast "undefined undefined" on every rejected form, invisible until now because ignoreBuildErrors: true hid the type errors.

Next.js 16.3, for contrast, cost one deletion: it removed the eslint key from NextConfig, so both next.configs failed typecheck until the key came out.

Which bugs did TypeScript 7 shake loose?

About nine real latent bugs, every one of them older than the upgrade itself.

TypeScript 7 checked the API in 5.9 seconds; on 5.9 the same check took on the order of a minute, from memory, not a stopwatch. Fast checks meant we actually drove the error count down instead of living with the backlog, and the shrinking list surfaced the bugs: a welcome-bonus insert that could never succeed (enum value not in the database), a POS location tree that was always empty (result.rows on a driver that returns the array directly), a stop-list filter silently dropping its balance condition (Drizzle's second .where() replaces the first), and a payment webhook reporting 1970 timestamps (new Date(null)). A "failing" test turned out to have been stale for a month, testing a policy the code had deliberately changed.

None of these were caused by the upgrade. All of them were found by it.

What should you do on your own stack?

Measure your noise floor before trusting any delta. Benchmark layers in isolation, or you will thank the wrong dependency. Run the codemod, then audit its diff like a junior engineer's PR, including package.json, which ours rewrote. Keep exactly one copy of Elysia in the tree. And treat the typecheck backlog as a bug list, because that's what ours turned out to be.

Where does ShipKit fit?

This ecommerce platform is one of the production codebases ShipKit is distilled from: same Bun + Elysia + Next.js + Drizzle stack, same conventions. ShipKit today ships Elysia 1.4 and the Next 16.3 range; it does not ship Elysia 2, because Elysia 2 is a beta, and this is exactly the kind of run we do before a stable release earns its place there. When the migration lands in ShipKit, it lands with these failure modes already mapped.

Next step: run the isolation benchmark on your own stack before crediting any single layer. ShipKit ships this same stack today, on Elysia 1.4 until a stable 2.x earns its place.

Sources