Migration
Step-by-step strategies for migrating existing Node.js applications to Deno or Bun
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 linesMigrating 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
| Aspect | Bun (easiest) | Deno (more changes) |
|---|---|---|
| Package manager | bun install (uses package.json) | JSR, URL imports, or npm: specifiers |
| Module system | CJS and ESM both work | ESM only (CJS via compat layer) |
| TypeScript | Native, zero config | Native, zero config |
| Node APIs | Most implemented | node: prefix required |
| Config files | package.json | deno.json |
| Test runner | Jest-compatible bun test | Deno.test (different API) |
| Lockfile | bun.lockb | deno.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.jsonandnode_modulesworkflow - The team is comfortable with Node conventions
Choose Deno when:
- You value explicit security (permissions model)
- You want to eliminate
node_modulesentirely - 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.jsonexportsresolution can cause subtle failures. Test each critical dependency on the target runtime before committing. -
Keeping
dotenvand other polyfill packages after migrating. Bun loads.envfiles automatically, and Deno supports--env. Polyfill packages forfetch,Buffer,URL, andTextEncoderconflict with built-in implementations. Remove them during migration. -
Ignoring the
__dirname/__filenameproblem. These Node globals do not exist in ESM. Code that uses them will fail on Deno and may behave unexpectedly on Bun. Replace withimport.meta.dirnameandimport.meta.filename(orimport.meta.urlfor 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-allinitially 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
__dirnameand__filename— not available in ESM. Useimport.meta.dirname(Deno 1.40+, Bun, Node 21+) orimport.meta.urlwithURL.require()in Deno — does not work without"nodeModulesDir": "auto"or thecreateRequireshim. Convert toimport.- 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,sharpworks on Bun but not Deno). - Global polyfills — Node globals like
Buffer,process,setImmediatemay not exist. Deno requires explicitimport { Buffer } from "node:buffer". Bun provides most Node globals. .envfile loading — Bun loads.envautomatically. Deno requires--envflag or a library. Neither behaves exactly likedotenv.package.jsonscripts withnode— replacenode src/index.jswithbun run src/index.tsordeno 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 underbun:testimports.
Install this skill directly: skilldb add deno-bun-skills
Related Skills
Bun Basics
Bun runtime fundamentals including speed optimizations, built-in APIs, and package management
Bun Bundler
Using Bun as a bundler for frontend assets and as a fast test runner
Compatibility
Node.js compatibility layers in Deno and Bun for running existing npm packages and Node APIs
Deno Basics
Deno runtime fundamentals including permissions, module system, and built-in tooling
Deno Deploy
Deno Deploy edge functions for globally distributed serverless applications
Elysia Bun
Elysia web framework on Bun for type-safe, high-performance HTTP APIs