Skip to main content
Technology & EngineeringDeno Bun289 lines

Compatibility

Node.js compatibility layers in Deno and Bun for running existing npm packages and Node APIs

Quick Summary28 lines
You are an expert in Node.js compatibility layers for Deno and Bun, enabling gradual adoption of modern runtimes while preserving access to the npm ecosystem.

## Key Points

- Implements Node built-in modules directly (`fs`, `path`, `http`, `crypto`, `child_process`, etc.)
- Supports both `require()` and `import`
- Reads `package.json` and installs to `node_modules`
- Supports many N-API native addons
- Aims for full Node API coverage; gaps are tracked and shrinking with each release
- Supports Node built-ins via `node:` prefix (`import fs from "node:fs"`)
- Runs npm packages via `npm:` specifier or `nodeModulesDir` mode
- Does not support `require()` natively (use `createRequire` from `node:module`)
- Most popular npm packages work; some with native addons do not
- Permissions model still applies — even `node:fs` calls need `--allow-read`
- **Always use the `node:` prefix** for Node built-in imports — it works across all three runtimes (Node 16+, Deno, Bun) and makes the intent explicit.
- **Prefer Web Standard APIs** (`fetch`, `Request`, `Response`, `URL`, `crypto.subtle`, `ReadableStream`) over Node-specific ones when building new code. They are portable by design.

## Quick Example

```bash
deno run --allow-net --allow-read --allow-env server.ts
```

```bash
bun run server.ts  # Just works
```
skilldb get deno-bun-skills/CompatibilityFull skill: 289 lines
Paste into your CLAUDE.md or agent config

Node.js Compatibility Layers — Modern JS Runtimes

You are an expert in Node.js compatibility layers for Deno and Bun, enabling gradual adoption of modern runtimes while preserving access to the npm ecosystem.

Overview

Both Deno and Bun implement substantial portions of the Node.js API surface to run existing npm packages and Node-style code. Bun targets near-complete API compatibility as a drop-in replacement. Deno takes a more deliberate approach, requiring the node: import prefix and providing a nodeModulesDir option for npm package resolution. Understanding what works, what partially works, and what breaks is critical for a smooth migration.

Core Concepts

Compatibility Approach by Runtime

Bun:

  • Implements Node built-in modules directly (fs, path, http, crypto, child_process, etc.)
  • Supports both require() and import
  • Reads package.json and installs to node_modules
  • Supports many N-API native addons
  • Aims for full Node API coverage; gaps are tracked and shrinking with each release

Deno:

  • Supports Node built-ins via node: prefix (import fs from "node:fs")
  • Runs npm packages via npm: specifier or nodeModulesDir mode
  • Does not support require() natively (use createRequire from node:module)
  • Most popular npm packages work; some with native addons do not
  • Permissions model still applies — even node:fs calls need --allow-read

Module Resolution Differences

FeatureNode.jsBunDeno
require("fs")YesYesNo (use createRequire)
import "fs"YesYesNo (use "node:fs")
import "node:fs"Yes (16+)YesYes
import "npm:pkg"NoNoYes
Bare specifiers (import "lodash")Via node_modulesVia node_modulesVia import map or nodeModulesDir
.ts file extensions in importsNo (needs tsc)YesYes

Implementation Patterns

Using Node Built-ins in Deno

// Deno requires the node: prefix for all Node built-in modules
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { createServer } from "node:http";
import { Buffer } from "node:buffer";
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { Readable, Transform } from "node:stream";

const content = readFileSync("./data.txt", "utf-8");
const hash = createHash("sha256").update(content).digest("hex");
const fullPath = path.resolve(".", "output", "result.txt");
writeFileSync(fullPath, hash);

Using npm Packages in Deno

// Option 1: npm: specifier (inline, no install step)
import express from "npm:express@4";
import { z } from "npm:zod@3";
import pg from "npm:pg@8";

// Option 2: Import map in deno.json
// {
//   "imports": {
//     "express": "npm:express@4",
//     "zod": "npm:zod@3"
//   }
// }
import express from "express";
import { z } from "zod";

// Option 3: nodeModulesDir mode (closest to Node workflow)
// deno.json: { "nodeModulesDir": "auto" }
// Then: deno install
// Then use bare specifiers as in Node

Using require() in Deno

import { createRequire } from "node:module";
const require = createRequire(import.meta.url);

// Now you can use require for CJS packages
const _ = require("lodash");
const config = require("./config.json");

Polyfilling Node Globals in Deno

// process — available automatically in Deno when running npm packages,
// or import explicitly:
import process from "node:process";
console.log(process.env.HOME);
console.log(process.argv);
console.log(process.cwd());

// Buffer — available automatically in npm packages,
// or import explicitly:
import { Buffer } from "node:buffer";
const buf = Buffer.from("hello", "utf-8");

// __dirname and __filename equivalents
const __filename = import.meta.filename; // Deno 1.40+
const __dirname = import.meta.dirname;   // Deno 1.40+

// For older Deno versions:
import { fileURLToPath } from "node:url";
import path from "node:path";
const __filename_compat = fileURLToPath(import.meta.url);
const __dirname_compat = path.dirname(__filename_compat);

Running Express on Deno

import express from "npm:express@4";
import cors from "npm:cors@2";

const app = express();
app.use(cors());
app.use(express.json());

app.get("/api/health", (_req, res) => {
  res.json({ status: "ok", runtime: "deno" });
});

app.listen(3000, () => {
  console.log("Express running on Deno at :3000");
});
deno run --allow-net --allow-read --allow-env server.ts

Running Express on Bun (No Changes Needed)

// Exact same code as Node — no modifications required
import express from "express";
import cors from "cors";

const app = express();
app.use(cors());
app.use(express.json());

app.get("/api/health", (_req, res) => {
  res.json({ status: "ok", runtime: "bun" });
});

app.listen(3000, () => {
  console.log("Express running on Bun at :3000");
});
bun run server.ts  # Just works

Cross-Runtime Portable Code

Write code that works on Node, Deno, and Bun:

// Detect runtime
function getRuntime(): "deno" | "bun" | "node" {
  if ("Deno" in globalThis) return "deno";
  if ("Bun" in globalThis) return "bun";
  return "node";
}

// Use Web Standard APIs for maximum portability
async function fetchData(url: string) {
  // fetch() works on all three runtimes
  const resp = await fetch(url);
  return resp.json();
}

// Use node: prefix — works on Node 16+, Deno, and Bun
import { readFile } from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";

Handling Conditional Imports

// Dynamic imports for runtime-specific code
async function getKV() {
  const runtime = getRuntime();

  if (runtime === "deno") {
    return await Deno.openKv();
  } else if (runtime === "bun") {
    const { Database } = await import("bun:sqlite");
    return new Database("kv.db");
  } else {
    const { Level } = await import("level");
    return new Level("./kv-store");
  }
}

Node API Compatibility Matrix

// WELL SUPPORTED on both Deno and Bun:
import fs from "node:fs";                // filesystem
import fsp from "node:fs/promises";      // async filesystem
import path from "node:path";            // path utilities
import { URL } from "node:url";          // URL parsing
import crypto from "node:crypto";        // cryptography
import { Buffer } from "node:buffer";    // binary data
import { EventEmitter } from "node:events"; // events
import { Readable, Writable } from "node:stream"; // streams
import os from "node:os";               // OS info
import util from "node:util";           // utilities
import querystring from "node:querystring"; // query strings
import assert from "node:assert";        // assertions

// PARTIALLY SUPPORTED (some methods may be missing):
import http from "node:http";           // HTTP server/client
import https from "node:https";         // HTTPS
import net from "node:net";             // TCP
import tls from "node:tls";             // TLS
import child_process from "node:child_process"; // subprocesses
import worker_threads from "node:worker_threads"; // workers
import zlib from "node:zlib";           // compression

// LIMITED or UNSUPPORTED:
// node:vm        — partial in Bun, limited in Deno
// node:inspector — not available
// node:trace_events — not available
// node:perf_hooks — partial
// node:diagnostics_channel — partial in Bun
// node:cluster   — not available in Deno, partial in Bun

Core Philosophy

The Node.js compatibility layers in Deno and Bun exist to solve a pragmatic problem: the npm ecosystem contains millions of packages, and requiring developers to rewrite everything from scratch would make modern runtimes unusable. Compatibility is a migration bridge, not a design goal. The intent is to let you run existing code today while gradually adopting the native APIs and module system of your chosen runtime.

Bun and Deno take fundamentally different approaches to compatibility, reflecting their different philosophies. Bun aims to be a transparent drop-in replacement where package.json, node_modules, require(), and bare specifiers all work exactly as they do in Node. Deno takes a more explicit approach, requiring the node: prefix for built-in modules and npm: specifiers for npm packages. Both strategies have merit: Bun minimizes migration effort, while Deno makes the compatibility boundary visible in the source code.

The node: prefix is the one convention that works across all three runtimes (Node 16+, Deno, Bun) and should be your default for writing portable code. When combined with Web Standard APIs (fetch, Request, Response, URL, crypto.subtle), you can write code that genuinely runs on any modern JavaScript runtime without modification. This portability is not theoretical; it is the foundation of cross-runtime frameworks like Hono and libraries that target the WinterCG standard.

Anti-Patterns

  • Importing Node built-ins without the node: prefix. While import fs from "fs" works in Node and Bun, it fails in Deno. Always write import fs from "node:fs" for cross-runtime compatibility.

  • Relying on native C++ V8 addons. Packages that use raw V8 C++ APIs (not N-API) will not work on Bun or Deno. Check whether your dependency uses N-API or has a pure JavaScript/Wasm alternative before committing to a migration.

  • Assuming process.env mutation behavior is identical across runtimes. In Node, setting process.env.FOO = "bar" affects child processes. In Deno, environment modification requires Deno.env.set() with permissions. In Bun, assignment works but propagation may differ. Test environment handling explicitly.

  • Shipping polyfills for APIs that are already built in. Some npm packages bundle polyfills for fetch, URL, Buffer, or TextEncoder that conflict with the runtime's native implementations. These cause type mismatches and subtle bugs. Remove unnecessary polyfill packages when migrating.

  • Mixing Node streams and Web streams without conversion. Piping a Node Readable to a Web WritableStream (or vice versa) fails silently or throws. Use Readable.toWeb() and Writable.toWeb() adapters to bridge the two stream APIs explicitly.

Best Practices

  • Always use the node: prefix for Node built-in imports — it works across all three runtimes (Node 16+, Deno, Bun) and makes the intent explicit.
  • Prefer Web Standard APIs (fetch, Request, Response, URL, crypto.subtle, ReadableStream) over Node-specific ones when building new code. They are portable by design.
  • Test on the target runtime early — install Bun or Deno in CI alongside Node and run tests on both before fully committing.
  • Use nodeModulesDir: "auto" in Deno during migration to avoid rewriting all imports at once.
  • Check the compatibility tables for your runtime version before assuming an API works. Both Deno and Bun publish detailed compatibility docs.
  • Isolate runtime-specific code behind abstraction layers so the bulk of your application remains portable.

Common Pitfalls

  • Missing node: prefix in Denoimport fs from "fs" fails in Deno. Always write "node:fs".
  • Native C++ addons — packages like better-sqlite3, canvas, or node-sass that use raw V8 C++ API will not work. N-API based addons generally work in Bun. Look for Wasm or pure JS alternatives.
  • Subtle behavior differences in node:http — both runtimes implement http.createServer but edge cases (keep-alive behavior, timeout handling, trailer headers) may differ from Node. Test HTTP-level behavior thoroughly.
  • process.env mutation — in Node, modifying process.env affects child processes. In Deno, Deno.env.set() is required (and needs --allow-env). In Bun, process.env assignment works but may not propagate identically.
  • package.json exports field — both runtimes support it, but resolution edge cases (conditional exports, self-referencing) may differ from Node. If an import fails, check the package's exports map.
  • Polyfill conflicts — some npm packages ship their own polyfills for fetch, URL, or Buffer that collide with built-in implementations. This causes type mismatches or runtime errors. Remove unnecessary polyfill packages.
  • Stream compatibility — Node streams and Web streams are different APIs. Mixing them (e.g., piping a Node Readable to a Web WritableStream) requires conversion. Use Readable.toWeb() and Writable.toWeb() adapters.
  • TypeScript configuration — Deno ignores tsconfig.json by default (uses deno.json compilerOptions). Bun reads tsconfig.json. Keep configuration in sync during migration.

Install this skill directly: skilldb add deno-bun-skills

Get CLI access →