We moved a production API from Node to Bun in an afternoon

bun nodejs sqlite performance javascript
Benchmarking a production API under Node and Bun before a reversible cutover

The API behind my owner console, the one collecting analytics events and uptime probes for every site on my platform, ran on Node for its whole life. One afternoon in July it started running on Bun, and nobody noticed, which was the point. This post has the real numbers, but the numbers are the least transferable part. The part worth stealing is the method: measure first, make the change reversible, and let the benchmark make the decision instead of the blog posts.

Runtime migrations have a terrible reputation because most of them are one-way bets. Someone reads a benchmark chart, rewrites the server against the new runtime's native APIs, and discovers the incompatibilities in production with no way back but a revert war. I think that failure mode is chosen, not inherent. Here's how this one avoided it.

Capture the baseline before you touch anything

Before changing a line, I benchmarked the Node deployment as it stood: latency percentiles on the live host through Caddy, test suite wall time, memory. Then I wrote a small harness, bench-api.mjs, that spawns the server with whichever runtime invokes it, waits for the health check, runs 120 sequential requests per endpoint after warmup, and records cold start, percentiles, and resident memory. Both runtimes would hit the same scratch copy of the production SQLite database over loopback, so the comparison would be runtime versus runtime, with everything else pinned.

That harness stayed in the repo. This matters more than any single result: the comparison is re-runnable forever, so when Bun 1.5 or Node 26 lands, the same command re-answers the question instead of someone re-arguing it.

Make the code dual-runtime so the decision stays cheap

The trap I wanted to avoid was coupling "does Bun work here" to "have we committed to Bun." So the code went dual-runtime instead of migrating. The database layer detects its host: under Bun it opens bun:sqlite wrapped in a shim, under Node it keeps using node:sqlite exactly as before. One contract, two drivers, and the Node path byte-for-byte unchanged.

The shim earned its keep immediately. bun:sqlite returns null for a missed row where node:sqlite returns undefined; the wrapper normalizes to the Node contract so no caller ever learns which driver answered. The other gap was the test runner: Bun can't yet execute node:test top-level registration, so a tiny testkit module re-exports the test primitives from node:test under Node and from bun:test under Bun. Changing one import line per test file was the entire test diff. After that, the full suite ran green under both runtimes: 130 passing under Node, 130 passing under Bun, zero failures either way.

This is the same discipline I argued for in Types without the build step: keep the platform's contracts in your own code, at the boundaries, so the pieces underneath stay swappable. A runtime is just the biggest dependency you have.

The numbers, without the hype

Loopback, same database, 120 requests per endpoint:

Metric Node 24 Bun 1.3
Cold start to first healthy response 152 ms 122 ms
Heavy endpoint p50 97.4 ms 76.9 ms
Heavy endpoint p99 143.6 ms 127.5 ms
Light endpoint p50 2.4 ms 0.5 ms
Server RSS after the run 136 MB 81 MB

Now the honest reading, because this is where runtime posts usually start lying. The heavy endpoint got about 20 percent faster, and that's the ceiling, because its time is dominated by SQLite doing actual work that no runtime swap accelerates. The light endpoint looks like a 5x win, but that's per-request runtime overhead shrinking on an endpoint that was already answering in 2 milliseconds; no user will ever feel it. If I'd been chasing latency, this migration would've been a mediocre use of an afternoon.

The number that justified the cutover is the last row. Resident memory dropped 40 percent, from 136 MB to 81 MB, and on a small VPS where this API shares a box with everything else I run, 55 MB of headroom is an operational gift. That's the whole self-hosting economics story from Replacing the SaaS bill in miniature: on one node you own, memory is the budget, and the runtime that holds less of it funds the next service.

The cutover was three lines, and so is the rollback

Because the code already ran under both runtimes, production cutover touched only the compose file: the image swaps to Bun's, the start command swaps node for bun, and the healthcheck invokes bun instead of node. Deploy with a health gate, and the proxy's retry window makes it a zero-downtime swap like any other redeploy. Then I verified the live service the paranoid way: health endpoint, the auth gate rejecting an unauthenticated request, POST bodies parsing, and a real analytics ingest write landing in the database.

Rollback is the same three lines pointed back at Node, and the database layer doesn't care either way, since both drivers speak the same SQLite file and WAL. That's what dual-runtime buys: Node stays a one-line escape hatch instead of a decision we'd have to re-litigate under pressure. I've argued that owning your tools means owning the pager; it also means owning the ability to change your mind at compose-file prices.

Takeaways

Work with Kleto

I am James Cowan, a product engineer and the founder of Kleto. Kleto is a product engineering agency that ships production software from strategy through handoff. Reversible, measured migrations like this one are exactly how we move production systems without betting the product on a blog post. If that matches your stack, contact Kleto and we will scope a sensible first step.

Recommended

What is the difference between Bun and Node? @jameslcowan bun, javascript
Types without the build step: why I chose JSDoc + tsc over TypeScript files @jameslcowan typescript, javascript
Replacing the SaaS bill: how I built my own analytics, uptime, and BI stack with agentic development @jameslcowan analytics, javascript

Recommended

What is the difference between Bun and Node? @jameslcowan bun, javascript
Types without the build step: why I chose JSDoc + tsc over TypeScript files @jameslcowan typescript, javascript
Replacing the SaaS bill: how I built my own analytics, uptime, and BI stack with agentic development @jameslcowan analytics, javascript

Search by title, tag, description, or the prose itself. Results appear as you type.