DEV Community

Cover image for Picking a JavaScript runtime in 2026
Ahmet Zeybek
Ahmet Zeybek

Posted on Originally published at zeybek.dev

Picking a JavaScript runtime in 2026

Two years ago you could wave the runtime question away. Node was the runtime. Bun was fast and new and broke on something in every project. Deno was principled, and nobody's dependencies worked on it. You picked Node and moved on.

In 2026 I run all three in production across four services, and the reasons for each are specific enough that I think the question deserves another look. None of them "won". They stopped converging and started to specialise, and each specialisation suits a different kind of service.

What Node did

What matters about Node over the last two years is that it closed most of the gaps that made the others attractive. Node 22 and 24 ship a test runner, a watch mode, fetch, WebSocket, a permission model, node --run for package scripts and, the big one, TypeScript.1 You run a .ts file with node file.ts and it works, as long as the file sticks to the erasable subset of TypeScript: no enums, no parameter properties, no namespaces. You can build a single file executable from the standard library, and the sqlite module is built in.

In 2024, half the reasons people tried Bun were "it runs TypeScript directly" and "it has a test runner". Node has both now. It's still slower to start and slower per request than Bun, by a margin that depends a lot on the workload, which I'll come to. But the feature reasons have mostly gone.

What Node has and the others don't, and I keep coming back to this, is that every library works on it. The ecosystem was built on Node's APIs and its module resolution, so when a package does something odd with require or native addons, it does it in the way Node expects.

What Bun did

Bun got stable, and that's the headline. The project that broke on something in every codebase in 2024 now runs the same Next.js app, the same Fastify service and the same test suites as Node in my projects, with two exceptions I'll name. Compatibility was the whole story of Bun 1.2 and 1.3, and it landed.

It's also fast where it counts for a certain kind of service. It starts around four times quicker than Node, which matters for serverless, for CLIs and for anything that spawns processes.2 The built in SQLite, Postgres and Redis clients beat the popular npm ones because they skip a layer. And on the plain request-response benchmarks everyone quotes, the HTTP server handles roughly two to three times Node's requests per second on the same hardware.

Then there's the ownership change. Bun was acquired in December 2025, so it went from a startup with a runway to a runtime with a large company behind it. Whether that makes it more or less attractive depends on how you feel about the company, but "will this project exist in three years" isn't the question it used to be.

The two exceptions in my projects were a native addon for image processing that ships a prebuilt binary for Node's ABI and not Bun's, and a test that relied on the exact ordering of process.nextTick against microtasks, which Bun schedules a little differently.3

What Deno did

Deno stopped trying to be a separate ecosystem. Since 2 it runs npm packages, reads package.json and supports Node's built in modules, and the compatibility is good enough now that the "nothing works" reputation is about two years out of date. It kept the part that was always the point: a permission model where a program can't read a file, open a socket or read an environment variable unless it was started with permission to, and a standard library versioned and audited as a single thing.

Deno is also the best runtime for running untrusted code, because the sandbox is the default instead of an option. That turns out to be exactly what one of my services needs.

The four services

A Next.js web application runs on Node. Bun can run it fine. But the framework's own testing and release process runs on Node, the deploy target runs Node, and under a framework that does its own heavy lifting, a faster runtime doesn't buy much. The app spends its time rendering and waiting on the database, and the runtime barely figures. I benchmarked it on Bun once, got about 8 percent better p50 latency, and decided that wasn't worth being the person filing a bug the framework team can't reproduce on their runtime.

A high-throughput internal API runs on Bun. It's Fastify, JSON in and out, a Postgres connection pool, about 4,000 requests a second at peak. This is the workload where the runtime matters, because there's not much else going on: parse a request, run a query, serialise a response. Moving from Node 22 to Bun took p99 from 31 ms to 14 ms and the instance count from six to three. It also let me swap the Postgres client for Bun's built in one, which dropped a dependency and another few milliseconds. None of the compatibility worries that would have stopped me in 2024 came up. The service has 34 dependencies and all of them work.

A CLI tool we distribute to customers runs on Bun, for the single-file executable and the startup time. bun build --compile produces a 90 MB binary that starts in under 30 milliseconds, and customers don't need anything else installed. Node can build single file executables now too, but the binary starts in about 120 milliseconds, and for a CLI that gets called in shell loops people notice that. Startup decided it.

A plugin runner runs on Deno. Customers upload small scripts that transform their data, and the service runs them. I didn't think twice about this one. A script a customer wrote runs with no filesystem, no network apart from the one endpoint it needs, and no environment, and the runtime enforces that, not some container boundary I'd have had to build.4 When the property you need is what a tool was built around, use that tool.

The decision, generalised

Across those four, the runtime matters when the runtime is where the time or the risk is.

Under a framework that does its own bundling, rendering and caching, the runtime is a small part of the cost, and staying inside the framework's own test matrix is worth more than a few percent. That's Node.

For a thin service where the request path is mostly the runtime itself, parse, query, serialise, Bun's speed is real, and the compatibility risk is small enough now to take. That's Bun.

For a CLI, startup time decides how it feels to use and the single file binary decides how it feels to install. That's Bun, or Node if the binary size is a deal breaker.

For running code you didn't write, the sandbox is the product. That's Deno.

For a long-lived service with a lot of native dependencies, or a team whose experience is entirely Node, the boring choice is still right, and it isn't close. That's Node.

How I measured, so you can argue with it

The numbers above came from a day of running each service on each runtime. The method matters more than the numbers, because the method is the part you should copy.

For the API service I built one container image per runtime from the same source, with the same dependencies and environment variables, and put all three behind the same load balancer with weighted routing: 10 percent of production traffic to each candidate and 70 percent to the incumbent, for 24 hours on a Tuesday. Real traffic, real database, real customers.5

I compared p50 and p99 latency per route, memory per instance after six hours, CPU per request, error rate, and cold start time from container start to the first successful health check. People forget the error rate. A runtime that's 30 percent faster and returns a malformed response on one route in ten thousand is broken, whatever its latency, and the only way to find that route is to send it real traffic.

Here's the API service over that day:

Node 22 Bun 1.3 Deno 2.4
p50 latency 9 ms 5 ms 8 ms
p99 latency 31 ms 14 ms 27 ms
Memory at 6h 410 MB 260 MB 380 MB
Cold start 1.9 s 0.5 s 1.4 s
Error rate 0.01% 0.01% 0.01%

The error rates match, which is the result I actually cared about. The latency gap is real, and it's what moved the service to Bun. On the Next.js app the same method gave a p50 gap of 8 percent and a p99 gap of 3 percent, and that's what kept it on Node.

The differences that still bite

Three years of compatibility work closed most of the gaps. A few are left, and you want to test for them on day one rather than run into them in week three.

Native addons. Anything built on N-API works on Node and mostly on Bun, and on Deno through its Node compatibility layer with more exceptions. Anything built directly on the older V8 bindings only works on Node. Run npm ls and look for packages with a binding.gyp or a prebuild directory. Each one is a test to run on the candidate runtime before anything else.

Scheduling. process.nextTick, microtasks, setImmediate and timers interleave on Node in a specific order that some libraries, and some tests, depend on without knowing it. Bun and Deno follow the spec for microtasks more strictly, and copy Node's non-standard ordering for the rest less exactly. If a test passes on one runtime and fails on another with no code change, this is usually why, and the fix is to stop depending on the order.

Module resolution. The node: prefix on built ins works everywhere now, and so does the exports field in package.json. They differ in the fallbacks: what happens when a package has a broken exports map, or relies on Node being willing to resolve a directory to its index.js somewhere the map doesn't mention. Node forgives these, Bun forgives most, and Deno forgives fewer.6

Test runners. Each runtime ships its own, and they aren't the same. A suite written against Node's node:test needs changes to run on bun test, mostly around mocking and module interception. I kept Vitest for suites that run on more than one runtime, since it runs on all three, and only used a built in runner for the service committed to one runtime.

On none of my four services did any of these take more than a day to sort out. Each of them would have taken a week if I'd found it in production instead of in the weighted rollout.

What I would stop doing

Stop picking a runtime by the HTTP benchmark. It measures a server returning a fixed string, and no service does that. Measure your own service on the runtime you're considering, with your own dependencies, for a day. It's a few hours of work, and it swaps every opinion in this post for a number.

Stop assuming Bun will break. It might, on a native addon or a scheduling edge case, and you'll find out in the first hour of that measurement. Finding out in the third week of production doesn't happen any more.

And stop treating this as a one-off decision for the whole organisation. The four services above are in the same company and on three runtimes, and what that costs us to operate is one more base image and one more line in the setup docs. That's a lot less than running the plugin runner on Node with a hand-built sandbox, or running the API on six instances instead of three.


Originally published at zeybek.dev.


  1. Type stripping is on by default in 24. ↩

  2. The bundled package manager installs about as fast as pnpm. ↩

  3. I fixed the test by not relying on it. ↩

  4. I could have done it on Node with the permission model, which has existed since 20, but Node's is opt in and coarser, and Deno's has been the whole design of the runtime for six years. ↩

  5. Synthetic load tells you how a runtime handles a benchmark, and a benchmark has never paged me at 3am. ↩

  6. A monorepo with older internal packages hits this more than a fresh project. ↩

Top comments (0)