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();
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");
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
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)");
Run: bun run read-package-name.ts → package 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)");
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 withbun:sqliteand adjusted the few calls we had; that removed the dependency and fixed the issue. -
Buffer and encoding: A library assumed Node’s
Bufferbehavior 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.nodeto 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 withbun run script.tsfrom 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 usingimport.meta.dir(Bun) orpath.join(__dirname, "config.json")(Node) so the path is relative to the script file. -
Lockfiles: We had both
bun.lockbandpackage-lock.jsonin 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
- Use Node-style
fs/path/cryptowhen you need Node compatibility; use Bun APIs when you are Bun-only and want simplicity or speed. - Check native addon support before migrating; replace with Bun built-ins (e.g.
bun:sqlite) where possible. - Do not rely on
process.versions.node; use feature checks (e.g.typeof Bun !== "undefined") for runtime-specific code. - Prefer paths relative to the script (
import.meta.diron Bun,__dirnameon Node) for config or assets so behavior does not depend on the current working directory. - 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)