modern-react-spa

Chapter 18

Bun & Deno — New JavaScript Runtimes for React SPAs

Bun and Deno for React SPAs in 2026 — runtime differences from Node.js, side-by-side benchmarks, and a migration playbook.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can articulate, in one paragraph each, what Bun and Deno are and how they differ from Node.js; run the book’s sample SPA under all three; decide whether your team should adopt one, stay on Node, or run them side-by-side in CI; and migrate a Node-based pipeline to a new runtime incrementally without disrupting feature work.

🧭 Prerequisites — A working Node + npm + Vite SPA (Ch 17). Awareness that “Node-compatible” is not “Node-identical.” Sample app: examples/ch18-runtimes/.

📌 Reminder — Node.js + npm is the primary stack throughout this book. This is the one chapter where Bun and Deno are first-class. Bun workspaces (the monorepo angle) get a dedicated deep dive in Ch 19.


🔹 18.1 The 2026 runtime landscape

Three runtimes ship JavaScript on the server in 2026. The question to start with is not “which is best” — it’s “which fits my team.”

┌─────────── Runtime snapshot (2026-05) ───────────┐
│ Node.js 22 LTS │ The default. Strong CJS legacy.  │
│ Bun 1.x        │ All-in-one: runtime + npm +      │
│                │ bundler + test runner.           │
│ Deno 2.x       │ Secure-by-default; npm: + jsr:   │
│                │ imports; permission model.       │
└──────────────────────────────────────────────────┘

Node.js is the lingua franca. Every team has fluency. Every tutorial assumes it. Every native module is tested against it first. It’s also the only one of the three with no compatibility footnotes — npm packages and CLI tools just work.

Bun (https://bun.sh) is Jarred Sumner’s “what if a JS runtime was actually fast” project. Built on JavaScriptCore (Safari’s engine) instead of V8. Ships as one binary that is the runtime, the package manager (bun install), a bundler (bun build), and a test runner (bun test). Major win: install speed, often 10× faster than npm.

Deno (https://deno.com) is Ryan Dahl’s “what if I redid Node with the 2010s lessons” project. Built on V8 + Rust (Tokio). The 2.x release dropped the early “URL imports everywhere” ideology and embraced npm — your existing package.json works. Major wins: secure-by-default (permission flags for net, fs, env), built-in TS, built-in test runner, and JSR (https://jsr.io), Deno’s typed-by-default package registry.

What each runtime ships out of the box

CapabilityNode 22Bun 1.xDeno 2.x
TypeScript without a build❌ (needs tsx)
Package manager❌ (use npm)bun installdeno install
Bundlerbun builddeno bundle
Test runner⚠️ node:testbun testdeno test
npm registry✅ (via npm:)
Web Standard APIspartial
Native module compat⚠️ improving⚠️ improving
Windowsimproving
Edge-deploy storymediumCloudflareDeno Deploy

Why the comparison table matters less than it looks

For a React SPA, your build pipeline is Vite. The runtime under it is doing two jobs: installing packages and shelling out to Vite’s tools. The third job — running your dev server’s middleware — is also Vite. The runtime isn’t doing much of the work you care about.

This is why “Bun is 10× faster than Node” headlines need translation. They’re usually about runtime APIs (HTTP server, file I/O) that your build tools use, not that your SPA uses. The honest pitch for Bun and Deno on the SPA side is: faster installs and faster CI, not “your dev server will be 10× faster.”


🔹 18.2 🥟 Bun

18.2.1 bun install vs npm install

The single most visible win. On a real SPA with ~1,200 transitive deps:

Cold install (no node_modules, no lockfile)
─────────────────────────────────────────
npm install   24.1 s
bun install    3.1 s         ◀── ~7× faster

Warm install (lockfile present, node_modules cleared)
─────────────────────────────────────────
npm install    4.2 s
bun install    0.6 s

Two things drive this:

  • Bun’s installer is written in Zig and avoids npm’s per-package fs syscall pattern.
  • Bun’s lockfile (bun.lockb) is a binary format — fast to read and write, harder to review in PRs.

The lockfile trade-off. package-lock.json diffs are noisy but reviewable; bun.lockb is opaque. Two practical responses:

  • Use bun pm ls output in PR descriptions to show what changed.
  • Set BUN_LOCKFILE_FORMAT=text (recent Bun versions) to emit bun.lock as text alongside the binary form.

⚠️ Don’t ship bun.lockb and package-lock.json in the same repo without a policy. Pick one as authoritative; emit the other (or none) for the other tool.

18.2.2 bun run and bunx

bun run <script> is npm run <script>. The difference is startup overhead: Bun reads package.json and forks the script in a few ms; npm adds ~150 ms of Node bootup before your script even starts.

For ten back-to-back script invocations (typical CI), that’s 1.5 s of saved wall-clock from one change.

bunx <package> is npx <package> — fetch and run a one-shot tool. Same speed delta.

⚠️ bun run honors package.json scripts but its env-var pass-through differs slightly from npm. Don’t rely on npm_config_* env vars surviving a bun run shell. Read them explicitly if you need them.

18.2.3 bun build for libraries

bun build is a bundler — for libraries, not for SPAs. The cross-reference is Ch 11 §11.3 where it appears alongside Vite library mode. For SPA production builds, keep using vite build. bun build’s sweet spot is “I’m shipping a @acme/ui package and want sub-second builds.”

bun build src/index.ts \
  --outdir dist \
  --target browser \
  --external react --external react-dom \
  --format esm

18.2.4 Bun + Vite — best-of-both

Vite is the bundler, Bun is the runtime underneath Vite. Two ways to wire it:

(a) package.json scripts default to Node, opt into Bun explicitly

{
  "scripts": {
    "dev":     "vite",                  // ← still uses Node
    "dev:bun": "bun --bun vite",        // ← runs Vite under Bun
    "build":   "vite build"
  }
}

Run with npm run dev or bun run dev — same result, Vite’s the bundler either way. Use dev:bun when you want to benchmark or stress-test Bun-under-Vite.

(b) bun --bun flag for everything

{
  "scripts": {
    "dev":   "bun --bun vite",
    "build": "bun --bun vite build"
  }
}

This forces Bun’s runtime under every script. The risk: some Vite plugins (@vitejs/plugin-legacy, anything that shells out to Node-only tools) may not work under Bun. Test before committing.

18.2.5 Caveats

  • Native modules. Bun’s Node-API support is good but uneven. sharp works; better-sqlite3 works as of Bun 1.x; less popular natives can fail in subtle ways. Verify before adopting on a project with native dep weight.
  • Windows. Bun for Windows ships and improves quarterly. Still has rough edges on very large workspace installs and on filesystem watching at scale. Test on your team’s slowest Windows laptop, not the M-series Mac.
  • Ecosystem gaps. Tools that hardcode node in their shebangs or shell out to node from inside their internals need adapters. Most major tools (Vite, Vitest, Playwright, ESLint, Prettier) have first-class Bun support; some niche ones lag.

🔹 18.3 🦕 Deno

18.3.1 Deno 2.x highlights

Deno 2 dropped the original “URL imports only” purity and embraced npm. The runtime now reads package.json, resolves node_modules, and uses npm: and jsr: specifiers side-by-side.

// All three are legal Deno 2.x imports:
import express from 'npm:express@4';                          // ← from npm
import { z } from 'jsr:@zod/zod@4';                           // ← from JSR
import { delay } from 'https://deno.land/std@0.224.0/async/delay.ts';  // legacy URL imports

JSR (https://jsr.io) is Deno’s typed registry. Every JSR package ships TypeScript source; the registry serves transpiled output to non-TS consumers automatically. For libraries you maintain, JSR-first publishing buys you free TS types without a tsc step.

Workspaces in deno.json — analogous to npm/Bun workspaces. Covered for the monorepo case in Ch 19.

18.3.2 deno install, deno task, deno test for a React project

📄 examples/ch18-runtimes/app/deno.json

{
  "nodeModulesDir": "auto",
  "tasks": {
    "dev":     "deno run -A npm:vite",
    "build":   "deno run -A npm:vite build",
    "preview": "deno run -A npm:vite preview",
    "test":    "deno run -A npm:vitest run"
  }
}
  • nodeModulesDir: "auto" — Deno creates node_modules/ like Node does. Without this, Deno keeps deps in its own global cache, which breaks Vite plugins that walk node_modules.
  • tasks.<name> — analogous to npm scripts. Run with deno task dev.
  • -A — grant all permissions for the duration of the task. Use stricter flags (--allow-net=localhost:5173 --allow-read) for production; -A for development.

Built-in test runner. deno test runs *.test.ts files with no extra deps. For runtime tests this replaces Vitest. For jsdom React tests, you still want Vitest — deno test doesn’t ship a DOM. Cross-link Ch 36 for the full testing-stack story.

18.3.3 Deno + Vite

Vite under Deno works in 2026, with two caveats:

  • Plugins that import Node-only globals at module load can fail. Deno shims most (process.env, Buffer, path) — but obscure ones can break. The --unstable-detect-cjs flag improves CJS interop for those cases.
  • Filesystem watching uses Deno’s watchFs, which has different timing characteristics than chokidar (Vite’s default under Node). HMR feels identical in practice; very dense edit-save patterns can drop a frame or two.

The nodeModulesDir: "auto" line in deno.json is what makes the whole thing work. Without it, Vite’s resolver can’t find its own plugins.

18.3.4 Security model — the permission flags

Deno’s headline feature pre-2.x and still its biggest differentiator. Every program starts with zero permissions. You opt in:

deno run --allow-net=api.acme.example --allow-read=. --allow-env src/index.ts

What this means for build pipelines:

  • A malicious postinstall script can’t reach the network, the filesystem outside the project, or env vars — unless the flags allow it. This is real supply-chain protection.
  • For building an SPA, you grant --allow-net (Vite serves over HTTP), --allow-read (read source), --allow-write=dist (emit build output). That’s about it.
  • For development, -A (all) is normal and fine. The protection is for CI and production.

📄 real-world example — a CI step that catches a malicious dep

- name: Test under Deno with explicit permissions
  run: |
    deno run \
      --allow-net=registry.npmjs.org,api.github.com \
      --allow-read=. \
      --allow-write=./dist \
      --allow-env=NODE_ENV,CI \
      npm:vite build

If a dep tries to read /etc/passwd or call evil.example, the job fails loudly. With Node, that same malicious dep runs without complaint.

18.3.5 Deno Deploy

Deno’s hosting product (https://deno.com/deploy). For a pure SPA, you mostly use it as static-asset hosting (like Cloudflare Pages, Vercel, Netlify). The interesting wrinkle: edge functions for things like the auth-callback handler (Ch 28 §28.1) run on the same platform, in V8 isolates, with sub-50ms cold start globally.

The static-hosting numbers are competitive but not differentiated. The edge-functions story is what would make you pick Deno Deploy specifically.

🔹 18.4 Side-by-side benchmarks

The examples/ch18-runtimes/bench/run-all.sh script produces a fresh table. On an M2 Pro / 32 GB / macOS 14.5, an illustrative run gives:

OperationNode 22 + npmBun 1.xDeno 2.x
Cold install (1 200 deps)24.1 s3.1 s5.4 s
Warm install (Node)4.2 s
vite build12.4 s11.8 s13.1 s
Vitest run (3 tests)1.8 s1.3 s1.9 s

The shape of this table is what matters more than the absolute numbers:

  • Install is where the runtimes differ dramatically. Bun ~7× over Node, Deno ~4× over Node. For a 100-developer org running this once per CI job, this is real money.
  • Build is within 10–15 % across runtimes. Rollup is doing the work; the runtime is shelling out.
  • Test is within 30 %. Vitest is doing the work; the runtime is hosting.

The CI-cost translation

If your CI fleet runs 10,000 jobs per month, each with one fresh install:

Node + npm:  10,000 × 24.1 s ≈ 67 hours of CI install time
Bun:         10,000 ×  3.1 s ≈  8.6 hours
Deno:        10,000 ×  5.4 s ≈ 15 hours

At commercial CI pricing (~$0.008/min on GitHub Actions), that’s ~$3,200/month → ~$400/month → ~$700/month. Real money, real argument for adoption.


🔹 18.5 Decision matrix — should you switch?

Seven questions. Score them honestly. Total wins decide.

QuestionBun-favouringDeno-favouringStay on Node
1. Team size > 50 and CI cost is a visible line item?+1+1
2. CI runs ≥10× per developer per day on average?+1+1
3. Native modules are critical (sharp, better-sqlite3, sentry-native)?+1
4. Many developers on Windows?+1
5. Security / supply-chain concerns are leadership-visible?+2
6. Edge deployment in the plan (Cloudflare, Deno Deploy)?+1 (Cloudflare)+1 (Deno Deploy)
7. Tolerance for “tools that almost work” is low?+1

Recommended adoption pattern by total

  • Node wins (4+): Stay. Revisit in 12 months.
  • Bun wins (4+): Adopt for bun install only first. Keep node for runtime. Migrate fully after 3 months if no regressions.
  • Deno wins (4+): Adopt as the CI runner. Keep Node for production servers (or move them too, more cautiously).
  • Tie: Default to Node. Move only when one runtime emerges as the clearly-better answer to a specific pain.

The non-quantifiable factor

Hiring. Node + npm fluency is universal. Bun and Deno fluency are not. A senior engineer can pick up either in a week, but they will spend that week. Multiply by your team size.


🔹 18.6 Migration playbook — Node → Bun / Deno incrementally

The pattern: never flip the team in one PR. Adopt in stages, with observability.

Stage 1 — Observer in CI (week 1)

Add a parallel CI job that runs the new runtime alongside Node. Don’t gate merges on it. Watch the failures and the times.

# .github/workflows/ci.yml
jobs:
  test-node:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/setup-node@v4
      - run: npm ci && npm test

  test-bun:
    runs-on: ubuntu-latest
    continue-on-error: true        # ← observer mode, doesn't block
    steps:
      - uses: oven-sh/setup-bun@v2
      - run: bun install && bun test

Stage 2 — Make scripts runtime-agnostic (weeks 1–2)

Audit package.json scripts. Anything that hardcodes node (e.g., node ./scripts/seed.js) won’t work under Bun or Deno without changes. Replace with bare commands when possible (./scripts/seed.js with a shebang).

Stage 3 — Switch one non-critical tool (weeks 2–4)

Pick something small: the changelog generator, the local API mock, a doc-site builder. Switch it to the new runtime. Watch for two weeks. Roll back if anything’s flaky.

Stage 4 — Decide (week 4–6)

Either:

  • Adopt the new runtime for install in CI (the biggest, most visible win).
  • Adopt fully (install + scripts + dev-server).
  • Back out, keeping Node.

Stage 5 — Documentation and onboarding

If you adopted: update your README, your contributor docs, your local-setup guide. The cost of “what version of node do I install” became “what version of node AND bun AND maybe deno do I install” — make it explicit.

┌─ Migration timeline (typical) ───────────────────────────┐
│ Week 1   │ Observer CI job added; data collected         │
│ Week 2-3 │ Scripts audited and standardised              │
│ Week 4   │ One non-critical tool switched                │
│ Week 6   │ Decision: adopt for CI install / adopt fully  │
│ Week 8+  │ Documentation, onboarding, follow-up          │
└──────────────────────────────────────────────────────────┘

⚠️ Don’t combine a runtime migration with another major change (framework upgrade, monorepo split). One variable at a time.


🪤 Common Pitfalls

  1. bun install assumed drop-in for npm install. Postinstall scripts and peer-dep resolution differ subtly. Run the test suite after first install on every project before celebrating the speed win.
  2. bun build for the SPA bundle. For SPAs, Vite is still the production bundler. bun build is for libraries (Ch 11).
  3. Deno without --allow-net. The dev server can’t fetch your API. Symptom: cryptic permission errors that don’t name what was denied. Fix: deno task dev with -A for dev, narrower flags for CI.
  4. Mixed bun.lockb and package-lock.json in one repo. Pick one as authoritative. Emit the other only via a CI step if you absolutely need both.
  5. Picking a runtime based on a microbenchmark instead of your team’s workflow. “Bun is 10× faster” is a real number for one specific thing (cold install). For your day-to-day, the difference is invisible most of the time.
  6. Treating native modules as “it’ll be fine.” They’re often not. If your stack depends on sharp / better-sqlite3 / sentry-native, test those before committing to a migration.
  7. bun run dev and deno task dev and npm run dev open the same port. That’s a configuration choice, not a Bun/Deno feature. Test that nothing’s running on 5173 first or all three step on each other.
  8. Deno’s nodeModulesDir: "auto" missing. Vite plugins fail mysteriously. Always set it for SPA work.
  9. Running CI under Bun without --frozen-lockfile. Bun’s install can update the lockfile on a mismatch. In CI you want strict reproducibility — bun install --frozen-lockfile.

✅ Recap

  • Node + npm remains the safest default. Bun and Deno are additions, not replacements, in 2026.
  • Bun’s biggest concrete win is install speed and an integrated toolchain. Deno’s biggest differentiator is the permission model and JSR.
  • For SPAs, the runtime is doing less work than the build tools running on top of it — translate “X is faster than Node” headlines into your specific workflow’s bottleneck.
  • Adopt incrementally: observer CI → script audit → one non-critical tool → decide. Never flip the team in a single PR.
  • Bun workspaces (the monorepo angle) get deeper coverage in Ch 19.
  • The right answer is team-shaped. Score the §18.5 matrix honestly; pick from there.

🔗 Further Reading

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (6)
  1. 18.1 The 2026 runtime landscape
  2. 18.2 🥟 Bun
  3. 18.3 🦕 Deno
  4. 18.4 Side-by-side benchmarks
  5. 18.5 Decision matrix — should you switch?
  6. 18.6 Migration playbook — Node → Bun / Deno incrementally