{"id":408,"date":"2026-07-02T05:05:15","date_gmt":"2026-07-02T05:05:15","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/bun-runtime-in-production-a-year-of-replacing-node\/"},"modified":"2026-07-02T05:05:15","modified_gmt":"2026-07-02T05:05:15","slug":"bun-runtime-in-production-a-year-of-replacing-node","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/bun-runtime-in-production-a-year-of-replacing-node\/","title":{"rendered":"Bun Runtime in Production: When I Replaced Node and When I Didn&#8217;t"},"content":{"rendered":"<p>Short version for the impatient: yes, I run Bun in production. No, it didn&rsquo;t replace every Node service I have. The thing that actually changed my mind wasn&rsquo;t raw runtime speed. That&rsquo;s been oversold for two years. It was <code>bun install<\/code>.<\/p>\n<p>I tried Bun the first time back in 0.6 days and bailed within an afternoon. Three native modules wouldn&rsquo;t compile, my test runner exploded on a TypeScript decorator, and the cherry on top was a segfault in production-mode <code>bun build<\/code> that I couldn&rsquo;t reproduce locally. I quietly closed the tab and went back to pnpm with tsx and Vitest, and I assumed that would be the story for a while.<\/p>\n<p>Then 1.2 shipped. I gave it another weekend. A year later, I run two services and most of my CI on Bun, and I have specific reasons for the Node services I didn&rsquo;t migrate. This post is that audit, with the code and the regressions, not the marketing.<\/p>\n<h2 id=\"why-i-gave-bun-another-shot\">Why I gave Bun another shot<\/h2>\n<p>The thing that pulled me back wasn&rsquo;t a benchmark. It was a Stripe webhook service I was rewriting that needed to do roughly three things: verify a signature, write a row to Postgres, fire a Slack notification. The whole service was about 200 lines of TypeScript. The Node version of it had grown a tsconfig, a Jest config with a ts-jest transformer, a Dockerfile that needed <code>node-gyp<\/code> for one transitive dep, and a CI job that took 90 seconds to install dependencies before it could run a 4-second test suite.<\/p>\n<p>That ratio felt absurd. So when <a href=\"https:\/\/bun.sh\/blog\/bun-v1.2\" rel=\"nofollow noopener\" target=\"_blank\">Bun 1.2 dropped with stable Node compat for the things I actually used<\/a>, I rebuilt the same service in Bun and measured.<\/p>\n<p>CI went from 94 seconds to 11. The test runner ran without configuration. The Dockerfile shrank to seven lines. I shipped it on a Friday and watched the metrics over a weekend. Nothing exploded.<\/p>\n<p>That&rsquo;s when I started looking at the rest of my Node services and asking which ones would actually benefit and which wouldn&rsquo;t.<\/p>\n<h2 id=\"bun-install-changed-the-shape-of-my-ci\">bun install changed the shape of my CI<\/h2>\n<p>This is the underrated part. Everyone talks about runtime perf, but for me the biggest day-one win is the package manager. On a medium TypeScript repo (around 700 deps in the resolved graph), here&rsquo;s what I measured last week on a cold runner:<\/p>\n<pre><code>npm install    \u2192 51s\npnpm install   \u2192 22s\nbun install    \u2192 4s\n<\/code><\/pre>\n<p>I&rsquo;ve ranted about package manager speed before in my <a href=\"https:\/\/abrarqasim.com\/blog\/pnpm-vs-npm-the-day-node-modules-stopped-eating-my-disk\" rel=\"noopener\">pnpm vs npm post<\/a>, and pnpm was already a big jump from npm. Bun is just another step. The lockfile is a binary format that resolves faster, and the installer hits the filesystem in a way that pnpm&rsquo;s content-addressed store doesn&rsquo;t quite match for cold installs.<\/p>\n<p>You can keep your existing <code>package.json<\/code>. Bun reads it. The first time you run <code>bun install<\/code>, it generates a <code>bun.lock<\/code> next to your <code>package-lock.json<\/code>. I left both checked in for a few weeks and confirmed they stayed in sync before deleting the npm lockfile.<\/p>\n<p>The interesting wrinkle: I use <code>bun install<\/code> in CI for every project now, even the ones still running on Node at runtime. You don&rsquo;t have to commit to Bun as a runtime to get the install speedup. That alone was worth the migration in a couple of repos where the install step was the longest part of CI.<\/p>\n<h2 id=\"bun-test-made-me-delete-jest\">bun test made me delete Jest<\/h2>\n<p>The other thing I didn&rsquo;t expect was the test runner. Jest with TypeScript has been a slow argument I&rsquo;ve been losing for years: ts-jest vs @swc\/jest, ESM compatibility, mock hoisting, <code>transformIgnorePatterns<\/code>. The Vitest migration helped but didn&rsquo;t eliminate the config sprawl.<\/p>\n<p>Bun ships a test runner that reads Jest-style globals (<code>describe<\/code>, <code>it<\/code>, <code>expect<\/code>) and runs <code>.ts<\/code> files directly. The migration in one of my repos was: delete <code>jest.config.js<\/code>, delete <code>jest-environment-jsdom<\/code>, delete <code>@types\/jest<\/code>, delete the ts-jest chain, change one CI line from <code>pnpm test<\/code> to <code>bun test<\/code>.<\/p>\n<p>Before:<\/p>\n<pre><code class=\"language-js\">\/\/ jest.config.js \u2014 38 lines I haven't read in two years\nmodule.exports = {\n  preset: 'ts-jest\/presets\/default-esm',\n  testEnvironment: 'node',\n  extensionsToTreatAsEsm: ['.ts'],\n  transform: {\n    '^.+\\\\.tsx?$': ['ts-jest', { useESM: true, isolatedModules: true }]\n  },\n  moduleNameMapper: {\n    '^(\\\\.{1,2}\/.*)\\\\.js$': '$1'\n  },\n  \/\/ ...etc\n};\n<\/code><\/pre>\n<p>After:<\/p>\n<pre><code class=\"language-jsonc\">\/\/ nothing. bun test just runs.\n<\/code><\/pre>\n<p>On the same test suite, Jest with ts-jest took 6.4s. Bun test took 0.9s. I want to be careful here: that ratio shrinks on larger suites where the test work dominates over startup. But on a small backend service it removes a meaningful chunk of inner-loop friction.<\/p>\n<p>Vitest is still my pick when I need the snapshot UI, jsdom-style component testing, or watch mode in a complex monorepo. For backend services, Bun&rsquo;s test runner has eaten most of my Jest config.<\/p>\n<h2 id=\"bunserve-replaced-my-express-skeleton\">bun.serve replaced my Express skeleton<\/h2>\n<p>The other place Bun won is server boilerplate. I had a tiny Express skeleton I copy-pasted into every new service: a logger middleware, a JSON body parser, a couple of routes, a health check. It always ended up being 60-ish lines.<\/p>\n<p>Here&rsquo;s the same shape in <code>Bun.serve<\/code> using the 1.2 routes API:<\/p>\n<pre><code class=\"language-ts\">Bun.serve({\n  port: 3000,\n  routes: {\n    &quot;\/health&quot;: new Response(&quot;ok&quot;),\n    &quot;\/webhook&quot;: {\n      POST: async (req) =&gt; {\n        const body = await req.json();\n        await processWebhook(body);\n        return Response.json({ received: true });\n      },\n    },\n  },\n});\n<\/code><\/pre>\n<p>That&rsquo;s it. There&rsquo;s no body parser package to install. JSON handling is built in. The routes API in 1.2 is closer to what I want than Express middleware ever was. Websockets are handled by the same primitive with a separate <code>websocket<\/code> config. I haven&rsquo;t had a websocket workload that pushed it yet, but the basic chat demo I wrote held up fine under a few thousand concurrent clients on a single core.<\/p>\n<p>If you want middleware patterns and a broader plugin ecosystem, <a href=\"https:\/\/hono.dev\" rel=\"nofollow noopener\" target=\"_blank\">Hono<\/a> runs natively on Bun and gives you the Express-ish ergonomics without dragging in <code>express<\/code> itself. That&rsquo;s what I reach for now when a service has more than four routes.<\/p>\n<h2 id=\"where-node-still-won\">Where Node still won<\/h2>\n<p>I want to be specific here, because the honest version of this post is about the things that didn&rsquo;t work.<\/p>\n<p>I tried to move an image processing service that uses <code>sharp<\/code>. The native module worked under Bun, but the cold-start time on AWS Lambda Node runtime vs running Bun inside a container was worse for my workload. I switched the same code back to Node 22 on Lambda&rsquo;s official runtime and saved about 200ms of cold start. Not Bun&rsquo;s fault. Lambda doesn&rsquo;t ship Bun as a managed runtime, and the container layer overhead is real for spiky traffic.<\/p>\n<p>I also kept a service on Node because I rely on Datadog&rsquo;s APM auto-instrumentation. Datadog&rsquo;s tracer hooks into Node&rsquo;s <code>async_hooks<\/code> in a specific way. Bun has its own implementation of <code>node:async_hooks<\/code>, but I had two traces showing up wrong when I tested last quarter, and I didn&rsquo;t have budget to debug Datadog vs Bun in production. It probably works now. I haven&rsquo;t re-tested. If you depend on heavy APM tracing, run a real check on your own workload before committing.<\/p>\n<p>The other thing that bit me: a small dependency used <code>process.binding<\/code>, a deprecated Node internal that Bun doesn&rsquo;t expose. The package hadn&rsquo;t been updated in three years. I forked it, removed the call, and submitted a PR. The PR has been open since March.<\/p>\n<p>The pattern: when something breaks on Bun in 2026, it&rsquo;s almost always a dependency reaching into a Node internal that nobody should be reaching into, or a native module that Lambda&rsquo;s runtime is better positioned for. The list of broken things shrinks every month. Bun keeps a <a href=\"https:\/\/bun.sh\/docs\/runtime\/nodejs-apis\" rel=\"nofollow noopener\" target=\"_blank\">Node API compatibility page<\/a> that&rsquo;s actually accurate now, which was not true a year ago.<\/p>\n<h2 id=\"how-i-decide-today\">How I decide today<\/h2>\n<p>I don&rsquo;t run Bun for everything. I run it where the tradeoffs lean in.<\/p>\n<p>A new internal HTTP service that does I\/O and talks to Postgres or Redis? Bun. The startup speed, the install speed, the test runner, and the lack of config tax all compound. It&rsquo;s just less friction in the loop where I actually spend time.<\/p>\n<p>Anything deployed on AWS Lambda where the managed Node runtime gives me cold-start advantages? Node 22. The official runtime ships with <a href=\"https:\/\/nodejs.org\/en\/blog\/release\/v22.0.0\" rel=\"nofollow noopener\" target=\"_blank\">modern V8 and ESM support<\/a>, and the cold-start cost of bundling Bun into a container layer isn&rsquo;t worth the migration for most of my Lambda surface.<\/p>\n<p>A monolith with two years of tsconfig, custom Jest setup, and a deep dep tree? I leave it alone and only swap <code>bun install<\/code> into CI. Lowest-risk, highest-leverage move and it doesn&rsquo;t commit me to anything at runtime.<\/p>\n<p>The thing I&rsquo;d push back on: do not migrate a stable, working Node service to Bun for runtime perf alone. The risk-adjusted payoff isn&rsquo;t there. Migrate when you&rsquo;re rewriting anyway, when CI is genuinely painful, or when your local test loop is slowing you down. That kind of iteration-speed win is the whole game in the small services I cover across my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio projects<\/a>.<\/p>\n<h2 id=\"one-thing-to-try-this-week\">One thing to try this week<\/h2>\n<p>Pick the noisiest CI job in your monorepo, the one where most of the runtime is <code>pnpm install<\/code> or <code>npm ci<\/code>, and add a parallel job that runs <code>bun install<\/code> instead. Don&rsquo;t change anything else. Just measure.<\/p>\n<p>If <code>bun install<\/code> finishes in a tenth of the time, you&rsquo;ve found the cheapest migration in your stack. Swap that one line and ship it. You don&rsquo;t have to touch the runtime, the test runner, or the rest of your tooling to get the win.<\/p>\n<p>That&rsquo;s how I started. A year later I have a much clearer answer to the question &ldquo;should I switch to Bun?&rdquo; than I did before: only where the tradeoffs lean in. Most of the time, in the kind of services I build, they do.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I ran Bun in production for a year alongside Node. Here is where it actually replaced Node at runtime, where I switched back, and the libraries that bit me.<\/p>\n","protected":false},"author":2,"featured_media":407,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I ran Bun in production for a year alongside Node. Here is where it actually replaced Node at runtime, where I switched back, and the libraries that bit me.","rank_math_focus_keyword":"bun runtime","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,165],"tags":[49,166,442,444,443,44,379,63],"class_list":["post-408","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-javascript","tag-backend","tag-bun","tag-bun-runtime","tag-bun-test","tag-bun-serve","tag-javascript","tag-node-js","tag-typescript"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/408","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=408"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/408\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/407"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=408"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=408"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=408"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}