Short version: Bun is the fastest JavaScript runtime I’ve put behind a real workload, and it is also the runtime I’ve had to debug at 11pm because a popular npm package shipped a postinstall script that quietly assumed node. Six months in, I’d do it again for the service I migrated. I would not do it for the next one. Here’s why.
I moved one service: a TypeScript-on-Express HTTP API that talks to Postgres, Redis, and a handful of HTTP upstreams. About 30k lines of TypeScript, 1500 dependencies in the lockfile, a couple of native modules. The kind of service that exists everywhere and that nobody ever feels like rewriting. The trigger was that our CI build was taking nine minutes and I was tired of it.
What I gained, measured
This is the part of the post that’s easy to write because the numbers are right there:
- CI cold install dropped from 95 seconds to 18 with bun install. That alone justified the migration internally.
bun testruns our suite (~2300 unit tests) in 6 seconds instead of 38. Same coverage, no rewrites, the bun:test API is mostly Jest-compatible.- Startup time of the HTTP server went from 1.4s to 380ms. We don’t restart often, but for tests that boot the app inside a
beforeAll, this is a real quality-of-life win. - Cold start on Fly.io machines dropped from ~600ms to ~120ms. Helps for scale-to-zero.
These numbers will be different for everyone. If your service spends 90% of its wall time in Postgres, the runtime gets you nothing. Our hot path is JSON parsing and crypto, which is where Bun’s native code shines.
What I lost, also measured
The tradeoffs nobody puts in their launch post:
- About 80 of our 1500 dependencies installed but didn’t work. The biggest categories: things that called
process.binding(), things with native addons compiled only against the Node ABI, and packages whosepackage.jsonhad wrongexportspaths that Node tolerated and Bun did not. - One ORM (not the one we use, but one we tried) hit a transaction-isolation bug under Bun’s Postgres driver that took two days to reproduce, even longer to isolate, and ended with me filing an issue and reverting to
node-postgresfor that path. - Source maps in error stacks are subtly different. Sentry needed a different uploader, and for about a week we had stack traces pointing at minified line numbers.
- IDE integration was rougher than I expected at the time. That has mostly been fixed in 2026, but it ate a day initially.
None of this is dealbreaking. All of it is real.
The three sharp edges
1. Postinstall scripts that assume node
This is the one that almost killed the migration. A popular logger package we depend on has a postinstall step that runs node ./scripts/check-env.js. Under Bun, node is not on the PATH in our Docker image because we removed it. Postinstall failed, build failed, deploy failed at 11pm on a Friday.
The fix was small: alias node to bun in the image. The lesson was that the npm ecosystem still has thousands of packages that hard-code node, and Bun does not fix those. You will get bitten. Make sure your image has a node shim or be ready to add one.
2. Native modules
We had two native modules in our tree. Both worked in the end but both required upgrading to a version that explicitly supports Bun. If you’re on an older better-sqlite3, an older argon2, or an older sharp, you will spend an afternoon. Newer versions either ship Bun-compatible binaries or work through Bun’s node-api shim.
The heuristic that worked for me: if a package’s release notes for the past year mention Bun at all, you’re probably fine. If they don’t, audit it before assuming.
3. The mental switch about node_modules
Bun installs into node_modules like npm does, but its resolution rules are subtly different. Specifically, Bun honors package.json#exports more strictly than older Node versions did. This is correct behavior, but it means a package that was reaching into another package’s internals will break on Bun and not on Node. We had two cases of this and both were trivially fixed by importing from the documented entry point, but “trivially fixed” came after “hour of confusion.”
When I would not pick Bun today
I ran the migration again on a side project this spring, and I rolled it back after a week. The service was a worker that runs Puppeteer to scrape a handful of pages. Puppeteer’s launcher does a bunch of Node-specific things, the failure mode was opaque, and the speedup I’d have gained on the runtime didn’t matter because the service spends 95% of its life waiting for Chrome.
My heuristic now: pick Bun when you’re CPU- or startup-bound on JavaScript work and when your dependency tree is mostly maintained. Skip it for projects whose hot path is dominated by other processes (databases, headless browsers, GPU workloads) or by ancient unmaintained packages.
I wrote about Bun vs Node from a more theoretical angle a few months back. This post is the practical companion: six months of actually running it on something that matters.
How I’d do the migration again
The order of operations that worked:
- Switch CI to
bun installfirst. This is the lowest-risk win and the highest-leverage feedback loop. - Run the test suite under
bun testnext. If your tests pass, ship that as a CI matrix entry. Now you have continuous evidence that the runtime works for your code. - Only after a couple of weeks of green CI under Bun, swap the runtime in your production Dockerfile. Roll out to one machine first.
- Keep a Node-based image building in parallel for a month. If anything goes sideways, you have a one-line rollback.
Don’t try to do all of this in one PR. The temptation is real because the runtime change feels atomic, but each step exposes a different class of problem.
One concrete thing to do this week
If you ship JavaScript anywhere, install Bun locally and run bun install against your repo. Just the install. Look at the output. Note which packages emit warnings. That is your migration cost, mostly, and it takes ten minutes to get the answer.
My recent work has included a few Bun migrations for teams that wanted the speedup without the surprise. Both ended fine. Both took longer than the launch posts implied.
Bun is good. Bun is not a drop-in replacement for Node in every codebase. Both of those statements are true at the same time and the bulk of the work is figuring out which one applies to yours.