The JavaScript runtime space has been “disrupted” so many times it should be on a drinking game card. Node was stable for a decade, then Ryan Dahl — the guy who built Node — came back and said “I made it wrong, here’s Deno.” Then a Zig-powered upstart called Bun showed up claiming to run everything faster than you can blink.
It’s 2026. All three are production-viable. The question is no longer “which one works” — it’s “which one is right for your situation.”
Let’s break it down.
The Contenders
Node.js is the incumbent. 15+ years old, npm ecosystem of ~2.5 million packages, runs half the internet’s backend. Node 22/24 finally baked in native TypeScript support (via --experimental-strip-types, then full transpilation in 24+), a native test runner, and native fetch. It’s boring. That’s a compliment.
Deno is the rewrite Ryan Dahl wished he’d done first. Secure by default — your script can’t read files, hit the network, or touch env vars without explicit flags. TypeScript is a first-class citizen, not an afterthought. Ships its own standard library. Single binary install, no node_modules by default (though npm compat exists via npm: specifiers). Deno Deploy and Deno KV round out the serverless story.
Bun is the chaos candidate — written in Zig, uses JavaScriptCore (Safari’s engine, not V8), and benchmarks like it’s been drinking rocket fuel. Startup time, install speed, raw HTTP throughput — Bun wins most of these races by a stupid margin. It also ships a bundler, test runner, package manager, SQLite client, S3 client, and WebSocket server all in one binary. Node API compat is increasingly solid.
Install Speed: The One Where Bun Wins Before You Even Run Code
This is Bun’s party trick. Run it once and you’ll never go back to watching npm install spin for 45 seconds on a fresh clone.
# npm on a ~100-dep project (cold cache)$ time npm installadded 98 packages in 34.2s
# bun on the same project (cold cache)$ time bun installbun install v1.2.x98 packages installed [1.84s]
# deno on the same project (cold cache, npm: specifiers)$ time deno install98 packages installed [18.6s]Bun is ~18x faster than npm here. That’s not a benchmark gotcha — it’s reproducible on real projects. Deno is faster than npm but slower than Bun; it’s doing more work to maintain its permission model and module graph.
Warm cache? All three are fast. But in CI, Docker builds, and fresh dev machine setups, Bun’s install speed is genuinely life-changing.
Cold Start: HTTP Server in 10 Lines
The canonical test. Spin up a minimal HTTP server and measure time-to-first-byte.
// Works in all three runtimes with minor differences
// Node.js (native http module, no TS transpilation needed in Node 24+)import { createServer } from "node:http";const server = createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello from Node");});server.listen(3000, () => console.log("Node listening on :3000"));// Bun — native Bun.serve APIBun.serve({ port: 3000, fetch(req) { return new Response("Hello from Bun"); },});console.log("Bun listening on :3000");// Deno — Deno.serve (stable since Deno 1.9)Deno.serve({ port: 3000 }, (_req) => { return new Response("Hello from Deno");});console.log("Deno listening on :3000");Startup benchmarks (time from CLI invocation to first request served):
| Runtime | Startup | Notes |
|---|---|---|
| Bun | ~6ms | JavaScriptCore, native server |
| Deno | ~18ms | V8, permission parsing overhead |
| Node | ~45ms | V8, module resolution |
Bun’s startup advantage matters most in serverless and edge environments where cold starts happen constantly. On a long-running server, it’s irrelevant.
TypeScript DX: Who Hates You the Least
This used to be Node’s biggest wart. Not anymore — but there are still caveats.
Node 24+: Native --experimental-strip-types strips type annotations and runs the file directly. No tsc, no ts-node, no build step for scripts. For serious projects you still want tsc for type-checking; Node’s stripping doesn’t validate types, it just removes them.
# Node 24+ — just run it$ node --experimental-strip-types server.tsNode listening on :3000
# Or in Node 24+ with full transpilation (handles decorators, etc.)$ node --experimental-transform-types server.tsDeno: TypeScript is fully supported with zero flags. deno run server.ts just works. Type checking happens automatically unless you opt out with --no-check. It’s the cleanest DX of the three.
$ deno run --allow-net server-deno.tsDeno listening on :3000Bun: Like Deno, TypeScript runs natively. Zero config, zero flags, just bun run. Also strips types rather than full type-checking at runtime, but the ergonomics are seamless.
$ bun run server-bun.tsBun listening on :3000Winner on DX: Deno (type-checks by default) and Bun (fastest iteration) are tied. Node is now competitive but still feels like it’s wearing the ergonomic improvements as a costume.
Native APIs: What’s Baked In
All three now ship fetch, WebSocket, ReadableStream, and basic Web APIs. The interesting differences are the extras.
Node ships with a solid stdlib: node:fs, node:http, node:crypto, node:stream. Native test runner (node:test) with TAP output. node:sqlite landed in Node 22. That’s about it — anything else comes from npm.
Deno ships the Deno standard library via JSR: file system helpers, HTTP server utilities, YAML/CSV parsers, testing utilities, semver, UUID, and more. If you want to write a CLI without reaching for npm, Deno’s stdlib often has you covered.
Bun ships surprises: Bun.file() for fast file I/O, Bun.sqlite (native SQLite binding — no better native SQLite exists), Bun.S3Client for S3-compatible storage, Bun.password for bcrypt/argon2, a built-in bundler (Bun.build), and a test runner (bun test, Jest-compatible API). If you’re building a backend and you want to avoid dependency bloat, Bun’s built-ins go surprisingly far.
// Bun native SQLite — no deps requiredimport { Database } from "bun:sqlite";
const db = new Database(":memory:");db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");db.run("INSERT INTO users (name) VALUES (?)", ["SumGuy"]);
const users = db.query("SELECT * FROM users").all();console.log(users); // [{ id: 1, name: 'SumGuy' }]Security Model: Deno’s Jail vs Everyone Else’s Open Door
Deno’s permission system is the most opinionated design choice of the three — and genuinely useful for untrusted scripts.
# Deno blocks everything by default$ deno run server-deno.tserror: Requires net access to "0.0.0.0:3000", run again with --allow-net
# Be explicit about what you allow$ deno run --allow-net=0.0.0.0:3000 server-deno.ts
# Full lockdown audit: what does this script actually need?$ deno run --allow-net --allow-read=./data --allow-env=DATABASE_URL server-deno.tsFor running third-party scripts or tools in CI, this is excellent. You can audit exactly what a script touches. For long-lived applications where you own the whole stack, it’s mostly friction — you’ll end up with --allow-all in your Dockerfile and you’ve defeated the purpose.
Node and Bun have no permission model. You run it, it can do whatever the OS user allows. That’s been true for 15 years and most production Node apps haven’t self-destructed, so take the risk model with appropriate context.
Package Management and the node_modules Problem
Node/npm: The classic. package.json, package-lock.json, node_modules/ that’s sometimes larger than your actual project. Workspaces exist and work. npm ci for reproducible installs. The ecosystem is here; this is where the packages live.
Bun: Drop-in npm replacement. Same package.json, same node_modules/ layout (so all your existing tooling works), but the bun.lockb binary lockfile replaces package-lock.json. Workspaces are supported. If you’re migrating an existing Node project, bun install is often a one-command speedup with zero other changes.
Deno: The philosophy rebel. Deno historically used URL imports (import { serve } from "https://deno.land/[email protected]/http/server.ts"), which is either elegant or terrifying depending on your tolerance for “the import is the version.” Deno 2 added proper package.json support and npm: specifiers, so you can now import npm packages directly. JSR (the new JS registry) is where Deno-native packages live. The migration story to a full npm-compat workflow exists but involves some friction.
Compatibility Warts: Where Each Runtime Still Bites You
Bun: Native modules (.node bindings, node-gyp build stuff) are hit-or-miss. If you’re using sharp, bcrypt (native), sqlite3, canvas, or any Rust-backed binding — check compat first. Bun’s Node API coverage is ~95%+ for pure-JS packages but native extensions can still fail. Also: JavaScriptCore behaves slightly differently from V8 in edge cases (mostly irrelevant, occasionally infuriating).
Deno: The npm compat story is much better in Deno 2 but occasionally a package uses Node internals that Deno’s shim layer doesn’t cover. Heavy framework ecosystems (Next.js, Remix) aren’t officially supported — Deno is happiest with frameworks built for Deno (Fresh, Hono) or simple scripts and APIs.
Node: The opposite problem — it runs everything because everything was written for it. The wart is the DX lag: TypeScript, modern ESM handling, and Web API compatibility all required years of committee-driven incremental patches. You’re not hitting compat walls, you’re hitting ergonomics walls.
Docker and Self-Hosting: Which One Behaves in a Container
All three work fine in Docker. The practical differences come down to image size and startup semantics.
# Node base image optionsFROM node:22-slim # ~80MBFROM node:22-alpine # ~50MB
# Bun base imageFROM oven/bun:1-slim # ~55MBFROM oven/bun:1-alpine # ~40MB
# Deno base imageFROM denoland/deno:2 # ~165MB (no slim variant yet)Bun’s Alpine image is the smallest. Deno’s image is larger because it bundles more runtime tooling. Node has the most image variants and the most documentation on hardening.
For serverless/edge (Cloudflare Workers, Lambda, Fly.io):
- Deno Deploy is Deno’s native edge platform — best DX if you’re in the Deno ecosystem
- Bun on Fly.io or Railway is fast and easy; the cold start advantage matters here
- Node on Lambda with esbuild bundling is the most battle-tested; half the enterprise world runs this way
Production Readiness: The Boring Honest Assessment
| Factor | Node | Deno | Bun |
|---|---|---|---|
| Ecosystem size | Massive | Growing | Node-compatible |
| Long-term stability | Proven | Good (v2 stable) | Getting there |
| Enterprise adoption | Dominant | Niche | Growing |
| Framework support | Everything | Fresh, Hono | Most Node frameworks |
| Monitoring/APM tooling | Excellent | Limited | Limited |
| Security model | None | Best | None |
| Install speed | Slowest | Medium | Fastest |
| Runtime perf | Good | Good | Best |
| Native SQLite | Node 22+ | Via npm | Built-in |
| TS out of box | Node 24+ | Yes | Yes |
The Verdict
Pick Node if:
- You’re deploying a production backend where the library ecosystem matters more than runtime speed
- Your team already knows it and your monitoring/APM/logging tooling is wired for it
- You’re running anything with native module dependencies (node-gyp builds, GPU bindings, etc.)
- “Boring and stable” is a feature, not a bug — and honestly, it usually is
Pick Bun if:
- You’re starting a new project and want a single tool for package management, bundling, testing, and running
- Your project is API/backend-heavy with moderate dependencies and you want the raw speed
- You’re building a CLI or script-heavy project and want native SQLite/S3/WebSocket without extra deps
- CI pipeline install times are costing you money or sanity
Pick Deno if:
- Security boundaries matter — running third-party code, CI scripts, or tools where you want to audit permissions
- You want the cleanest TypeScript-first DX and are willing to be on the smaller-ecosystem side
- You’re building for Deno Deploy or edge compute and want the native platform alignment
- You’re tired of
node_modules/and want to try a different philosophy
The future has all three. Node won’t die — too much infrastructure depends on it. Bun will eat more greenfield projects as its compat story matures. Deno will own the security-conscious niche and edge compute. The JavaScript ecosystem finally has meaningful competition at the runtime layer, and that’s good for everyone — even if it means your “which runtime should I use” Slack thread now has three heated factions instead of one.
Your 2 AM self, staring at a container that won’t start because of a native module, will appreciate having thought about this ahead of time.