Picking a JavaScript runtime in 2026
Two years ago the runtime question was easy to dismiss. 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, on four services, and the reasons for each are specific enough that I think the question is worth taking seriously again. Not because any of them "won". Because they stopped converging and started specialising, and the specialisations map onto different kinds of services.
What Node did
The important thing about Node in 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. Type stripping is on by default in 24: you run a .ts file with node file.ts and it works, as long as the file uses only the erasable subset of TypeScript, no enums, no parameter properties, no namespaces. A single file executable can be built from the standard library. The sqlite module is built in.
Half of the reasons people had for trying Bun in 2024 were "it runs TypeScript directly" and "it has a test runner". Node has both now. It is still slower to start and slower per request than Bun, by a margin that depends heavily on the workload and that I will get to. But the feature reasons are largely gone.
What Node has that the others do not, and this is the thing I keep coming back to, is that every library works on it. The ecosystem was built on Node's APIs and its module resolution, and when a package does something strange with require or with native addons, it does the strange thing in the way Node expects.
What Bun did
Bun got stable. That is the headline. The project that broke on something in every codebase in 2024 runs, in 2026, the same Next.js app, the same Fastify service and the same test suites as Node in my projects, with two exceptions I will name. The compatibility work was the whole story of Bun 1.2 and 1.3 and it landed.
It is also fast in the ways that matter for a particular kind of service. Startup is around four times quicker than Node, which matters for serverless and for CLIs and for anything that spawns processes. The bundled package manager installs about as fast as pnpm. The built in SQLite, Postgres and Redis clients are faster than the popular npm ones because they skip a layer. And the HTTP server, on the plain request-response benchmarks that everyone quotes, does roughly two to three times the requests per second of Node on the same hardware.
And there is the ownership change. Bun was acquired in December 2025, which took it 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" stopped being the question it was.
The two exceptions in my projects: one native addon for image processing that has a prebuilt binary for Node's ABI and not for Bun's, and a test that relies on the exact ordering of process.nextTick versus microtasks, which Bun schedules slightly differently and which I fixed by not relying on it.
What Deno did
Deno stopped trying to be a different ecosystem. Since 2, it runs npm packages, reads package.json, and supports Node's built in modules, and the compatibility is now good enough that the "nothing works" reputation is out of date by about two years. What it kept is the part that was always the point: a permission model where a program cannot read a file, open a socket, or read an environment variable unless it was started with permission to, and a standard library that is versioned and audited as one thing.
Deno is also the runtime with the best story for running untrusted code, because the sandbox is the default rather than an option. That turns out to be exactly the property one of my services needs.
The four services
A Next.js web application. Node. Not because Bun cannot run it, it can, but because the framework's own testing and release process runs on Node, the deploy target runs Node, and the marginal gain from a faster runtime under a framework that does its own heavy lifting is small. The app's time is spent in rendering and in waiting for the database, not in the runtime. I benchmarked it on Bun once, got about 8 percent better p50 latency, and decided that was not worth being the person who reports a bug the framework team cannot reproduce on their runtime.
A high-throughput internal API. Fastify, JSON in and out, a Postgres connection pool, about 4,000 requests a second at peak. Bun. This is the workload where the runtime matters, because there is not much else: parse a request, run a query, serialise a response. Moving from Node 22 to Bun took the p99 from 31 ms to 14 ms and the instance count from six to three. It also let me replace the Postgres client with Bun's built in one, which removed a dependency and another few milliseconds. The compatibility concerns that would have stopped me in 2024 did not come up. The service has 34 dependencies and all of them work.
A CLI tool distributed to customers. 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 who install it do not need to have anything else installed. Node can do the single file executable now too, and the resulting binary starts in about 120 milliseconds, which for a CLI that is invoked in shell loops is a difference people notice. Startup was the deciding factor.
A plugin runner. Customers upload small scripts that transform their data, and the service runs them. Deno, without a second thought, because a script that a customer wrote runs with no filesystem, no network except the one endpoint it needs, and no environment, enforced by the runtime rather than by a container boundary I had to build. I could have done this on Node with the permission model, which exists since 20, but Node's is opt in and coarser, and Deno's has been the whole design of the runtime for six years. When the property you need is the thing a tool was built around, you use that tool.
The decision, generalised
The pattern in those four is that 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 compatibility with the framework's own test matrix is worth more than a few percent. 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 now small enough to take. Bun.
For a CLI, startup time dominates the user experience and the single file binary dominates the install experience. Bun, or Node if you cannot accept the binary size.
For running code you did not write, the sandbox is the product. Deno.
For a long-lived service with a lot of native dependencies, or one where the team's experience is entirely Node, the boring choice is still the right one, and it is not close. Node.
How I measured, so you can argue with it
The numbers above came from a day of running each service on each runtime, and the method matters more than the numbers, because the method is what you should copy.
For the API service I built one container image per runtime from the same source, same dependencies, same environment variables, and deployed all three behind the same load balancer with weighted routing: 10 percent of production traffic to each candidate, 70 percent to the incumbent, for 24 hours on a Tuesday. Real traffic, real database, real customers. Synthetic load tells you how a runtime handles a benchmark, and I have never had a benchmark page me at 3am.
The metrics I compared were p50 and p99 latency per route, memory per instance after six hours, CPU per request, error rate, and cold start time measured from container start to first successful health check. The error rate is the one people forget. A runtime that is 30 percent faster and returns a malformed response on one route in ten thousand is not faster, it is broken, and the only way to find that route is to send it real traffic.
Here is 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 is what moved the service to Bun. On the Next.js app, the same method produced a p50 gap of 8 percent and a p99 gap of 3 percent, and that is the number that kept it on Node.
The differences that still bite
Three years of compatibility work closed most gaps. A few remain, and they are the ones to test for on day one rather than discover in week three.
Native addons. Anything built on N-API works on Node and mostly on Bun, and works on Deno through its Node compatibility layer with more exceptions. Anything built on the older V8 bindings directly works only 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 in a specific order on Node that some libraries, and some tests, depend on without knowing it. Bun and Deno follow the specification for microtasks more strictly and 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. The exports field in package.json is honoured everywhere. Where they diverge is the fallbacks: what happens when a package has a broken exports map, or relies on Node's willingness to resolve a directory to its index.js in a place the map does not mention. Node forgives these. Bun forgives most. Deno forgives fewer. A monorepo with older internal packages hits this more than a fresh project.
Test runners. Each runtime ships one. They are not the same, and a suite written against Node's node:test will need changes to run on bun test, mostly around mocking and module interception. I kept Vitest for the suites that run on more than one runtime, because it runs on all three, and used the built in runner only for the service that is committed to one runtime.
None of these took more than a day to work through on any of my four services. All of them would have taken a week if I had found them in production instead of in the weighted rollout.
What I would stop doing
Stop picking a runtime by the HTTP benchmark. The benchmark measures a server that returns a fixed string, and no service does that. Measure your own service, on the runtime you are considering, with your own dependencies, for a day. That is a few hours of work and it replaces every opinion in this post with a number.
Stop assuming Bun will break. It might, on a native addon or a scheduling edge case, and you will find out in the first hour of that measurement. The days of finding out in the third week of production are over.
And stop treating this as a one-time decision for the whole organisation. The four services above are in the same company and on three runtimes, and the operational cost of that is one more base image and one more line in the setup docs. It is a much smaller cost than running the plugin runner on Node with a hand-built sandbox, or running the API on six instances instead of three.