DEV Community

caishengold
caishengold

Posted on

Bun vs Node.js: Differences That Matter When You Switch

Bun vs Node.js — differences that matter when you switch

Chen had a Node script that read config from a JSON file and called a few APIs. He ran it with bun run script.ts and it worked. Then he added code that used require("fs").promises and a package that relied on Node’s Buffer and process.versions. Some things broke: different globals, different resolution for node_modules, and one native addon that did not ship a Bun binary. By 2026-02-13 he had adjusted imports and one dependency to get a clean run on Bun.

This doc summarizes the main differences between Bun and Node so you can migrate or support both runtimes without surprises. The runnable examples use both runtimes so you can compare behavior locally.

Runtime and globals

Both runtimes provide globalThis, console, setTimeout, Promise, and the usual JS globals. Node exposes global; Bun does too for compatibility. Bun adds Bun (with Bun.file, Bun.serve, Bun.env, etc.) and uses the same fetch and Web APIs that Node 18+ provides. So code that uses fetch and Response works in both. Code that uses Bun.file() or Bun.serve() only runs on Bun.

// Runs on both Node 18+ and Bun
const res = await fetch("https://example.com");
const text = await res.text();

// Bun only
const file = Bun.file("./config.json");
const data = await file.json();
Enter fullscreen mode Exit fullscreen mode

If you want one codebase for both runtimes, hide runtime-specific code behind a small adapter or feature check (e.g. typeof Bun !== "undefined").

Runnable example 1: runtime detection. The same script can run on Bun or Node and report which runtime it is. Use a feature check instead of process.versions.node (which is undefined on Bun).

// examples/runtime-check.mjs
console.log("runtime:", typeof Bun !== "undefined" ? "Bun" : "Node");
console.log("process.versions.node:", process.versions?.node ?? "undefined");
Enter fullscreen mode Exit fullscreen mode

From the examples/ folder:

bun runtime-check.mjs
# runtime: Bun
# process.versions.node: undefined

node runtime-check.mjs
# runtime: Node
# process.versions.node: 20.10.0
Enter fullscreen mode Exit fullscreen mode

Relying on process.versions.node to detect Node would fail on Bun; the feature check works in both.

Runnable example 2: path relative to script. Reading a file next to the script so it works regardless of current working directory. On Bun use import.meta.dir; on Node (ESM) use import.meta.url and fileURLToPath.

Bun (examples/read-package-name.ts):

const path = `${import.meta.dir}/package.json`;
const file = Bun.file(path);
const data = (await file.json()) as { name?: string };
console.log("package name:", data?.name ?? "(none)");
Enter fullscreen mode Exit fullscreen mode

Run: bun run read-package-name.tspackage name: bun-runtime-patterns-examples.

Node (examples/read-package-name.mjs):

import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const path = join(__dirname, "package.json");
const data = JSON.parse(readFileSync(path, "utf8"));
console.log("package name:", data?.name ?? "(none)");
Enter fullscreen mode Exit fullscreen mode

Run from examples/: node read-package-name.mjs → same output. In both cases the path is relative to the script file, not the current working directory.


Module resolution and TypeScript

Node resolves require("foo") and ESM import "foo" from node_modules and follows package.json main/exports. Bun does the same and also resolves TypeScript (.ts, .tsx) without a separate compile step. So you can run bun run app.ts and Bun will resolve ./lib.ts and npm packages. Node does not run .ts natively; you need ts-node, tsx, or a build step.

Bun prefers ESM but supports CommonJS. Dynamic import() works in both. Some packages that assume Node’s exact resolution or use require.resolve in a specific way can behave differently on Bun; we hit this with one legacy package that expected a single node_modules at a fixed depth.


Built-in APIs: fs, path, crypto

Node’s fs, path, crypto, and stream are available in Bun with a compatible API. So code like fs.promises.readFile, path.join, or crypto.randomBytes usually works. Bun also provides its own APIs for the same jobs (e.g. Bun.file(), Bun.write()). They are often faster or simpler for new code but are Bun-only. For maximum compatibility with Node, stick to the Node-style built-ins.


Native addons

Node native addons (C++ or N-API) are compiled for Node’s ABI. Bun has its own runtime and does not guarantee that every Node addon will load. Some addons ship a Bun build; many do not. If your dependency list includes a package with native bindings (e.g. better-sqlite3, sharp), check the docs or try bun install and run your script. If the addon fails to load, you either keep that part on Node or replace the dependency with a Bun alternative (e.g. bun:sqlite instead of better-sqlite3).


Package manager

Bun ships with its own package manager (bun install, bun add). It uses the same package.json and lockfile format is different from npm’s. In practice we use either bun install or npm install for a project and do not mix them in the same tree; CI and local dev use the same one. node_modules layout can differ; if you rely on hoisting or specific paths, test with the manager you intend to use.


Comparison: when to use which

Criterion Prefer Bun Prefer Node
New script or small API Yes — fast startup, built-in SQLite/HTTP/test
Must support native addons Only if they support Bun Yes — broad addon support
Existing Node codebase Try run as-is; fix incompatibilities Stay on Node if migration cost is high
TypeScript without build Yes — native .ts execution Use ts-node/tsx or build
Single binary / minimal deps Bun + bun build for one file Node + esbuild or similar
Shared hosting / tooling If Bun is installed or allowed When only Node is available

Supporting both runtimes. If you need the same codebase to run on Node and Bun, keep Node-compatible APIs for I/O and use conditional checks for Bun-only features (e.g. typeof Bun !== "undefined"). Avoid relying on process.versions.node or Bun-specific module resolution in shared code.


Lessons learned

  • Native addon: We moved a small service to Bun; one dependency used better-sqlite3. It failed to load with a runtime error. We replaced it with bun:sqlite and adjusted the few calls we had; that removed the dependency and fixed the issue.
  • Buffer and encoding: A library assumed Node’s Buffer behavior at a boundary (encoding). On Bun it worked for our tests but broke in one edge case. We normalized the data to a format both runtimes handled (e.g. base64 or plain strings) before passing it to the library.
  • process.versions: Our code checked process.versions.node to decide whether to use a Node-specific path. On Bun that was undefined and we took the wrong branch. We switched to checking for the feature we needed (e.g. typeof Bun !== "undefined") instead of the Node version.
  • Working directory: A script used fs.readFileSync("./config.json") and we ran it with bun run script.ts from different directories in CI vs local. The path was relative to the current working directory and we got "file not found" in CI. We fixed it by using import.meta.dir (Bun) or path.join(__dirname, "config.json") (Node) so the path is relative to the script file.
  • Lockfiles: We had both bun.lockb and package-lock.json in the repo at different times. CI and local used different package managers and sometimes installed different versions. We picked one (Bun or npm) and removed the other lockfile so installs were consistent everywhere.

Takeaways

  1. Use Node-style fs/path/crypto when you need Node compatibility; use Bun APIs when you are Bun-only and want simplicity or speed.
  2. Check native addon support before migrating; replace with Bun built-ins (e.g. bun:sqlite) where possible.
  3. Do not rely on process.versions.node; use feature checks (e.g. typeof Bun !== "undefined") for runtime-specific code.
  4. Prefer paths relative to the script (import.meta.dir on Bun, __dirname on Node) for config or assets so behavior does not depend on the current working directory.
  5. Standardize on one package manager (Bun or npm) per project and use the same one in CI and locally.

All topics in this pack (bun:sqlite, Bun.serve, bun test, bun build) are Bun-specific. Use the comparison table above to decide when to stay on Node or adopt Bun for new or existing code.

Examples: examples/runtime-check.mjs (run with bun runtime-check.mjs or node runtime-check.mjs), examples/read-package-name.ts (Bun), examples/read-package-name.mjs (Node). Run from the examples/ directory.

Top comments (0)