Paul Mosquera Staff Applied AI & Platform Engineer
All writing

Node.js Is Dead, Long Live Bun

The king is dead, long live the king. The runtime changed. The API didn't. Our CPU bill did.

  • bun
  • platform
  • performance
  • agents

We moved every backend service on our platform from Node.js to Bun. On the first service we measured, at the same traffic, CPU usage dropped about 25%, CPU throttling went from constant to zero, and p99 latency fell from 60–80 ms to 35–50 ms.

No service needed a rollback. No team had to touch its observability code. And the work itself was done by AI agents in OpenCode: they migrated the services through a reusable skill, and built the observability library that unblocked the whole project in about two hours.

This is how we did it, what almost stopped us, and why the title is only half a joke.

What we did

From Node.js to Bun in six steps: load test, unblock, pilot, skill, rollout, measure. Steps 2 to 4 were done by AI agents in OpenCode.

Three of the six steps were done by AI agents; people decided, reviewed and measured.

Context

We run a payments platform in Latin America. Every backend service is NestJS, deployed on Kubernetes, owned by several product teams.

The goal was not novelty. We wanted two things: better performance per request, and a smaller infrastructure footprint to cut costs. Some services were hitting their CPU limits every business day and being throttled by Kubernetes, which shows up as tail latency.

Why Bun (and not Deno)

We did not pick Bun on benchmarks from the internet. We ran load tests on our own services with both Bun and Deno.

Deno did not show enough gain to justify the change. Bun did. Just as important, Bun runs NestJS and our existing npm dependencies as they are, so the migration did not mean a rewrite.

We accepted the risks knowingly: a younger runtime, a smaller production track record, and tooling in our stack that assumed Node.

The real blocker: observability

The hard part was not the runtime. It was our APM.

We use Dynatrace, and its agent instruments Node.js processes by injecting itself into the runtime. In our tests, it did not instrument Bun processes. A migrated service simply went dark: no distributed traces, no service metrics.

No team will accept losing observability in production in exchange for better latency. If we wanted every team to migrate, this had to be solved once, at the platform level, and not service by service.

One line in bunfig.toml

We built an internal library on top of OpenTelemetry. It instruments everything our services do: incoming HTTP, outgoing calls, the database through TypeORM, Redis, and Kafka through KafkaJS.

We did not spend a sprint on it. We built it with OpenCode, an open-source AI coding agent, in about two hours. Our part was knowing what it had to do: which libraries to instrument, how to stay compatible with the APM, and how to keep traces connected with the services still on Node. The agent wrote the code.

A blocker like this is usually what pushes a migration to next quarter. Here it took an afternoon.

Teams enable it with one line in bunfig.toml. Bun’s preload runs the library before the application code, so there is nothing to import and nothing to change in the service:

text
preload = ["@your-org/otel-bun"]

The library also reuses the configuration that the APM’s Kubernetes operator already injects into every pod. That detail matters more than it seems: a migrated service keeps its identity in the APM. Same service, same dashboards, same alerts, same history. Traces stay connected end to end with the services that were still on Node during the rollout.

Before: the APM operator injects an agent into the Node.js runtime. After: the same operator injects config, an OpenTelemetry library preloaded in Bun reports over OTLP to the same APM service.

The operator and the APM did not change. Only what runs inside the pod did, and it loads through one line of config.

From the point of view of operations, nothing changed. From the point of view of a developer, changing runtime became changing a base image and adding one line of config.

The playbook: an AI agent with a skill

We started with a single service in production and watched it closely. Once it behaved, we moved product by product until every service ran on Bun.

The migration itself was done by an AI agent in OpenCode. Once the first services were done, we packaged what it had learned into a skill: the steps, the checks, the edge cases. Today every engineer on the team has that skill, and migrating a service is a guided, repeatable task instead of a research project.

The skill knows the exact changes our services need: the preload line for the telemetry library, the import type fixes, and the checks to run before a service ships. An engineer points OpenCode at a service; the agent makes the changes; the engineer reviews them and decides when it goes out.

That changed the role of the team. Nobody spent weeks doing a runtime migration by hand. People reviewed, decided and moved on, while the agent did the repetitive part the same way every time.

Migration playbook: pilot, watch, skill, rollout, default.

This is the part I would underline for any platform team. The runtime is the headline, but what scaled was the knowledge, written down once and executed many times. I wrote about this idea in I Didn’t Write the Code. I Wrote the Rules.

What broke

Almost nothing. NestJS, TypeORM, Redis and KafkaJS all ran unchanged. No service had to be rolled back.

The one recurring fix was imports. Some files imported interfaces and types with a regular import instead of import type, and with decorators that is enough to crash at startup:

text
SyntaxError: Export named 'UserProfile' not found in module './user-profile.ts'

The reason is how each tool compiles. tsc sees the whole program, so when a decorated property uses an interface, it knows the type has no runtime value and writes Object into the decorator metadata. Bun transpiles each file on its own, so it emits a runtime reference to the interface, and that export does not exist in JavaScript (oven-sh/bun#8439). NestJS and TypeORM rely on emitDecoratorMetadata everywhere, so this shows up fast.

The fix is mechanical:

ts
// before
import { UserProfile } from './user-profile';

// after
import type { UserProfile } from './user-profile';

To keep it from coming back, enable verbatimModuleSyntax in tsconfig.json: the compiler then rejects any type imported without import type.

The numbers, honestly

The cleanest comparison is one service, the last full business day on Node against the first full day on Bun, at practically the same traffic. Each point below is that day’s daytime peak.

Same traffic, about 25% less CPU, zero throttling. Daily peaks indexed to the last full day on Node.

Approximate values read from Kubernetes and APM dashboards · one service · 26 Sep–2 Oct 2026

Memory per pod did not change, so the gain is in CPU, not memory.

p99 latency down about 40%, p50 nearly halved.

Approximate values read from APM dashboards · one service · 26 Sep–2 Oct 2026

All three percentiles stepped down the day Bun went live and stayed down.

The 25% CPU figure is conservative. On Node the service was being throttled, so it wanted more CPU than its limit allowed; the real demand was higher than what we measured. The night-time floor did not move, which tells us the gain is in the cost per request, not at idle.

CPU throttled every day on Node, zero since the switch to Bun.

Approximate values read from Kubernetes metrics in the APM · one service · 26 Sep–3 Oct 2026

On Node, throttling followed traffic every day and only eased off at night. It disappeared the night we switched.

The p99 drop follows from the throttling. When Kubernetes throttles a container, the requests that land in a throttled window wait, and those are exactly the slowest ones. Remove the throttling and the tail shrinks more than the median.

Three caveats, because a technical reader will ask:

  • CPU and throttling come from Kubernetes, not from inside the process. Those numbers do not depend on how we instrumented the service, and they are the strongest evidence here.
  • Latency is measured by the instrumentation, and that changed: the APM agent before, our OpenTelemetry library after. Both measure server-side request duration, but they may start and stop the clock at slightly different points. A 30–40 ms change at p99 is far beyond that; treat the p50 change with more care.
  • Every day there is a p99 spike in the early morning, before and after the migration, with similar size. It is a traffic pattern, not the runtime.

What’s next

A faster service does not save money by itself. The savings come when you give the capacity back.

We are still watching the migrated services before touching their CPU requests, limits and replica counts. Once the new baseline is stable across a few weeks of real traffic, including month-end peaks, we will right-size them. That is where the 25% turns into a smaller bill.

So, is Node.js really dead?

For us, as a runtime, yes. Bun is now the default for every new service and every existing one.

But the title is a play on “the king is dead, long live the king”, and in that phrase the crown never dies. Bun works because it speaks Node’s language: its APIs, its module system, npm, NestJS. We changed the engine without changing the car. Node.js the runtime is gone from our platform; Node.js the ecosystem is the reason the migration was a config change instead of a rewrite.

If you are thinking about doing the same, my advice is short. Run load tests on your own services, not on hello-world benchmarks. Solve observability before you ask any team to migrate; with an AI agent, that is hours of work, not a project. And write the migration down once, as rules an agent can execute, so the hundredth service costs less than the first.