Skip to main content
Technology & EngineeringDeno Bun316 lines

Migration

Step-by-step strategies for migrating existing Node.js applications to Deno or Bun

Quick Summary18 lines
You are an expert in migrating existing Node.js applications to Deno or Bun runtimes with minimal disruption.

## Key Points

- You want the fastest migration path with minimal code changes
- Your project relies heavily on npm packages with native addons
- You want to keep `package.json` and `node_modules` workflow
- The team is comfortable with Node conventions
- You value explicit security (permissions model)
- You want to eliminate `node_modules` entirely
- You are building for Deno Deploy (edge)
- You prefer URL-based or JSR imports
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun test
- run: bun build src/index.ts --outdir dist
skilldb get deno-bun-skills/MigrationFull skill: 316 lines
Paste into your CLAUDE.md or agent config

Migrating from Node to Deno/Bun — Modern JS Runtimes

You are an expert in migrating existing Node.js applications to Deno or Bun runtimes with minimal disruption.

Overview

Migrating from Node.js to Deno or Bun can yield faster startup, better TypeScript integration, and modern APIs. Both runtimes aim for Node compatibility but differ in approach: Bun is a near drop-in replacement that reuses node_modules and package.json, while Deno requires more structural changes but offers a cleaner module system and security model. This guide covers assessment, incremental migration, and common translation patterns for both targets.

Core Concepts

Migration Spectrum

AspectBun (easiest)Deno (more changes)
Package managerbun install (uses package.json)JSR, URL imports, or npm: specifiers
Module systemCJS and ESM both workESM only (CJS via compat layer)
TypeScriptNative, zero configNative, zero config
Node APIsMost implementednode: prefix required
Config filespackage.jsondeno.json
Test runnerJest-compatible bun testDeno.test (different API)
Lockfilebun.lockbdeno.lock

Decision Framework

Choose Bun when:

  • You want the fastest migration path with minimal code changes
  • Your project relies heavily on npm packages with native addons
  • You want to keep package.json and node_modules workflow
  • The team is comfortable with Node conventions

Choose Deno when:

  • You value explicit security (permissions model)
  • You want to eliminate node_modules entirely
  • You are building for Deno Deploy (edge)
  • You prefer URL-based or JSR imports

Implementation Patterns

Phase 1: Assessment

Create an inventory of what your project uses:

# List all dependencies
cat package.json | jq '.dependencies, .devDependencies'

# Find Node built-in usage
grep -rn "require('fs')\|require('path')\|require('http')\|require('crypto')" src/
grep -rn "from 'fs'\|from 'path'\|from 'http'\|from 'crypto'" src/

# Find CommonJS patterns
grep -rn "module.exports\|require(" src/

# Check for native addons
find node_modules -name "*.node" 2>/dev/null | head -20

Phase 2: Migrating to Bun

Step 1 — Swap runtime and package manager:

# Install Bun
curl -fsSL https://bun.sh/install | bash

# Replace node_modules
rm -rf node_modules package-lock.json
bun install

# Test that the app starts
bun run src/index.ts

Step 2 — Update scripts in package.json:

{
  "scripts": {
    "dev": "bun run --watch src/index.ts",
    "start": "bun run src/index.ts",
    "test": "bun test",
    "build": "bun build src/index.ts --outdir dist --target node"
  }
}

Step 3 — Replace Node-specific APIs where beneficial:

// Before (Node)
import { readFile } from "fs/promises";
const content = await readFile("config.json", "utf-8");
const config = JSON.parse(content);

// After (Bun — faster, simpler)
const config = await Bun.file("config.json").json();
// Before (Node + Express)
import express from "express";
const app = express();
app.get("/", (req, res) => res.json({ ok: true }));
app.listen(3000);

// After (Bun native — optional, Express still works on Bun)
Bun.serve({
  port: 3000,
  fetch(req) {
    return Response.json({ ok: true });
  },
});

Step 4 — Migrate tests:

// Before (Jest)
test("adds numbers", () => {
  expect(add(1, 2)).toBe(3);
});

// After (bun:test — same API, just change the import)
import { test, expect } from "bun:test";
test("adds numbers", () => {
  expect(add(1, 2)).toBe(3);
});

Phase 3: Migrating to Deno

Step 1 — Create deno.json from package.json:

{
  "tasks": {
    "dev": "deno run --watch --allow-net --allow-read --allow-env main.ts",
    "test": "deno test --allow-read"
  },
  "imports": {
    "@std/http": "jsr:@std/http@1",
    "@std/assert": "jsr:@std/assert@1"
  },
  "nodeModulesDir": "auto"
}

Step 2 — Convert CommonJS to ESM:

// Before (CJS)
const express = require("express");
const { readFileSync } = require("fs");
module.exports = { handler };

// After (ESM)
import express from "npm:express";
import { readFileSync } from "node:fs";
export { handler };

Step 3 — Update import paths:

// Before (Node)
import { readFile } from "fs/promises";
import path from "path";
import { createHash } from "crypto";

// After (Deno — add node: prefix)
import { readFile } from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";

// Or use Deno-native APIs
const content = await Deno.readTextFile("./data.json");
const joined = new URL("./sub/file.txt", import.meta.url).pathname;
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));

Step 4 — Convert npm packages:

// Option A: npm: specifier (easiest)
import chalk from "npm:chalk@5";
import { z } from "npm:zod@3";

// Option B: Import map in deno.json (recommended for larger projects)
// deno.json: { "imports": { "chalk": "npm:chalk@5", "zod": "npm:zod@3" } }
import chalk from "chalk";
import { z } from "zod";

// Option C: Replace with Deno-native or JSR packages
import { bold, red } from "jsr:@std/fmt@1/colors";

Step 5 — Migrate tests:

// Before (Jest)
import { add } from "./math";

describe("math", () => {
  test("adds numbers", () => {
    expect(add(1, 2)).toBe(3);
  });
});

// After (Deno)
import { assertEquals } from "jsr:@std/assert";
import { add } from "./math.ts";

Deno.test("math - adds numbers", () => {
  assertEquals(add(1, 2), 3);
});

// Or with BDD-style (closer to Jest)
import { describe, it } from "jsr:@std/testing/bdd";
import { expect } from "jsr:@std/expect";

describe("math", () => {
  it("adds numbers", () => {
    expect(add(1, 2)).toBe(3);
  });
});

Framework-Specific Translations

Express to Hono (works on all runtimes):

// Before (Express)
import express from "express";
const app = express();
app.use(express.json());
app.get("/users/:id", async (req, res) => {
  const user = await db.getUser(req.params.id);
  res.json(user);
});
app.listen(3000);

// After (Hono — portable across Node, Deno, Bun, Cloudflare)
import { Hono } from "hono";
const app = new Hono();
app.get("/users/:id", async (c) => {
  const user = await db.getUser(c.req.param("id"));
  return c.json(user);
});
export default app; // Bun/Deno serve this automatically

CI/CD Updates

# GitHub Actions — Bun
- uses: oven-sh/setup-bun@v2
  with:
    bun-version: latest
- run: bun install
- run: bun test
- run: bun build src/index.ts --outdir dist

# GitHub Actions — Deno
- uses: denoland/setup-deno@v2
  with:
    deno-version: v2.x
- run: deno fmt --check
- run: deno lint
- run: deno test --allow-read --allow-net

Core Philosophy

Migration from Node.js to Deno or Bun should be driven by concrete, measurable benefits, not by novelty. Faster startup times, better TypeScript integration, simpler toolchains, and improved security are valid motivations. "It is newer" is not. Before migrating, establish baseline metrics (startup time, request throughput, memory usage, build time, install time) so you can measure whether the migration delivered on its promise.

The migration should be incremental, not wholesale. Start with the test suite or a single microservice, run both runtimes in CI, and gain confidence before migrating critical production services. This approach limits blast radius: if the new runtime has a compatibility issue with a dependency, you discover it in a low-stakes environment rather than in production at 3 AM.

Both Bun and Deno have designed their compatibility layers to make the first step easy and the last step optional. You do not need to rewrite your entire codebase to adopt a new runtime. On Bun, swapping node for bun in your start script is often sufficient. On Deno, enabling nodeModulesDir and adding node: prefixes gets most projects running. The native APIs (Bun.serve, Deno.serve, Deno.openKv) are there when you want them, but they are not required for the migration to succeed.

Anti-Patterns

  • Migrating the entire monolith at once. Rewriting all services, scripts, and tooling in a single migration creates a massive, hard-to-debug change. Start with one service or the test suite, validate it works, and expand gradually.

  • Assuming all npm packages work identically. Most popular packages work, but edge cases in stream handling, native addons, and package.json exports resolution can cause subtle failures. Test each critical dependency on the target runtime before committing.

  • Keeping dotenv and other polyfill packages after migrating. Bun loads .env files automatically, and Deno supports --env. Polyfill packages for fetch, Buffer, URL, and TextEncoder conflict with built-in implementations. Remove them during migration.

  • Ignoring the __dirname/__filename problem. These Node globals do not exist in ESM. Code that uses them will fail on Deno and may behave unexpectedly on Bun. Replace with import.meta.dirname and import.meta.filename (or import.meta.url for older runtime versions).

  • Deploying to production without runtime-specific CI. Running tests only on Node and deploying on Bun or Deno hides compatibility issues until production. Add the target runtime to your CI pipeline and run the full test suite against it.

Best Practices

  • Migrate incrementally — start with the test suite or a single service, not the entire monolith at once.
  • Keep Node as a fallback during migration — run both runtimes in CI until the new runtime is stable.
  • Use nodeModulesDir: "auto" in Deno during migration to keep npm packages working without rewriting every import.
  • Run the existing test suite first — most tests will pass on Bun unchanged; on Deno, run with --allow-all initially and tighten permissions later.
  • Benchmark before and after — document concrete improvements in startup time, memory, and throughput to justify the migration.
  • Update Dockerfiles to use official runtime images (oven/bun, denoland/deno).

Common Pitfalls

  • __dirname and __filename — not available in ESM. Use import.meta.dirname (Deno 1.40+, Bun, Node 21+) or import.meta.url with URL.
  • require() in Deno — does not work without "nodeModulesDir": "auto" or the createRequire shim. Convert to import.
  • Native addons — some npm packages with C++ addons (e.g., bcrypt, sharp) may not work on Bun or Deno. Look for pure JS/Wasm alternatives (bcryptjs, sharp works on Bun but not Deno).
  • Global polyfills — Node globals like Buffer, process, setImmediate may not exist. Deno requires explicit import { Buffer } from "node:buffer". Bun provides most Node globals.
  • .env file loading — Bun loads .env automatically. Deno requires --env flag or a library. Neither behaves exactly like dotenv.
  • package.json scripts with node — replace node src/index.js with bun run src/index.ts or deno run --allow-all src/index.ts.
  • Test runner differences — Jest globals (jest.fn(), jest.mock()) do not exist in Deno. Bun mostly supports them but under bun:test imports.

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

Get CLI access →