MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Deno 2.0: Node.js Compatibility Deep Dive

How Deno 2.0 achieves Node.js compatibility: polyfills, package.json, and npm support.

DenoNode.jsCompatibilityRuntime

By MinhVo

Introduction

When Ryan Dahl introduced Deno at JSConf EU 2018, he explicitly positioned it as a correction to Node.js's design mistakes—no node_modules, no package.json, ES modules only, and a security-first permission model. For years, the Deno and Node.js ecosystems existed as parallel universes, each with fervent advocates. Deno 2.0 shatters that divide by delivering comprehensive Node.js compatibility that lets you run the vast majority of npm packages without modification.

This is not a superficial shim layer. Deno 2.0 supports package.json natively, resolves npm dependencies through the standard node_modules algorithm, polyfills virtually every Node.js built-in module, and handles CommonJS require() calls with full circular-dependency support. The result is a runtime that offers Deno's ergonomic advantages—built-in TypeScript, a unified toolchain, granular permissions—while unlocking the entire npm ecosystem of over 2 million packages.

Deno runtime architecture

Understanding Deno 2.0: Core Concepts

The Journey from Deno 1.x to 2.0

Deno 1.x was a purist's runtime. It rejected node_modules entirely, using URL-based imports and a centralized cache managed by the DENO_DIR environment variable. Dependencies were fetched from URLs—https://deno.land/std/ for the standard library, https://esm.sh/ for npm packages repackaged as ES modules. This was elegant but created friction: every library author had to maintain a Deno-compatible distribution, and developers couldn't simply npm install their favorite packages.

The turning point came with the npm: specifier introduced in Deno 1.28. For the first time, you could write import express from "npm:express" and have Deno fetch, resolve, and execute the package. But the implementation was incomplete—many packages relied on Node.js-specific globals (process, Buffer, __dirname) or built-in modules (crypto, stream, child_process) that Deno didn't fully emulate.

Deno 2.0 closes these gaps. The release notes highlight three pillars of Node.js compatibility: native package.json support, comprehensive built-in module polyfills, and a CommonJS loader that handles the full require() specification including dynamic requires and circular dependencies.

How npm Resolution Works in Deno 2.0

When you import a package in Deno 2.0, the resolution pipeline follows this sequence:

  1. Check package.json: If a package.json exists in the project root, Deno reads the dependencies and devDependencies fields to determine expected versions.
  2. Resolve from node_modules: If node_modules exists, Deno uses the standard Node.js resolution algorithm—walking up the directory tree until it finds the package.
  3. Fetch from npm registry: If the package isn't locally available, Deno downloads it from the npm registry and caches it in DENO_DIR.
  4. Evaluate exports field: Modern packages use the exports field in package.json to define entry points. Deno respects conditional exports (import, require, node, default) and selects the appropriate resolution.
  5. Apply polyfills: If the package imports a Node.js built-in (e.g., import fs from 'fs'), Deno redirects to its compatibility polyfill.

JavaScript runtime ecosystem

Architecture and Design Patterns

The Polyfill Layer Architecture

Deno's Node.js compatibility is implemented as a layered system within the deno_core Rust crate and the std/node TypeScript standard library.

Layer 1: Native Translation — APIs that map directly between Deno and Node.js. For example, Deno.readFile() translates to fs.readFile() with a callback wrapper.

Layer 2: Behavioral Emulation — APIs with different semantics require translation logic. child_process.spawn() in Node.js uses forked processes, while Deno uses Deno.Command(). The polyfill translates arguments, handles stdio piping, and emits Node.js-compatible events.

Layer 3: Pure Implementation — Complex APIs like crypto have no direct Deno equivalent. These are implemented using Web Crypto APIs or vendored third-party libraries.

// How the fs.readFile polyfill works internally
// deno_std/node/_fs/_fs_readFile.ts
import { readFile as denoReadFile } from "../../../deno.ts";
 
export function readFile(
  path: string | URL,
  options: { encoding?: string; flag?: string },
  callback: (err: Error | null, data: string | Uint8Array) => void
): void {
  const encoding = options?.encoding ?? null;
 
  denoReadFile(path)
    .then((data: Uint8Array) => {
      if (encoding) {
        const decoder = new TextDecoder(encoding);
        callback(null, decoder.decode(data));
      } else {
        callback(null, data);
      }
    })
    .catch((err: Error) => {
      callback(err, "");
    });
}

The CommonJS Loader

Deno 2.0 includes a complete CommonJS loader that handles:

  • require() resolution: Following Node.js's module resolution algorithm
  • Circular dependencies: Using a partial-module caching strategy identical to Node.js
  • Dynamic requires: require() with computed expressions
  • module.exports and exports: Both assignment patterns work correctly
  • JSON modules: require('./config.json') parses and caches JSON files
// circular-a.js
const b = require('./circular-b');
console.log('a:', b.value);
module.exports = { value: 'from a' };
 
// circular-b.js
const a = require('./circular-a');
console.log('b:', a.value); // {} - partial module
module.exports = { value: 'from b' };
 
// Both modules load without errors, matching Node.js behavior

Global Object Injection

Deno 2.0 automatically injects Node.js globals when running compatibility code:

// These globals are available without imports in Deno 2.0
console.log(process.env.HOME);        // Works
console.log(Buffer.from('hello'));     // Works
console.log(__dirname);                // Works in CJS context
console.log(__filename);               // Works in CJS context
 
// For ESM code, use these alternatives:
import process from "node:process";
import { Buffer } from "node:buffer";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Step-by-Step Implementation

Setting Up a Deno 2.0 Project with npm Dependencies

# Install Deno 2.0
curl -fsSL https://deno.land/install.sh | sh
deno --version  # Verify: deno 2.x.x
 
# Create project directory
mkdir deno-node-compat && cd deno-node-compat

Create a package.json:

{
  "name": "deno-node-compat",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "express": "^4.21.0",
    "pg": "^8.13.0",
    "zod": "^3.23.0",
    "lodash-es": "^4.17.21"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/pg": "^8.11.0"
  }
}

Install dependencies:

# Use npm to install (creates node_modules)
npm install
 
# Or use Deno's built-in installer
deno install

Building a Full Express Application

// server.ts
import express, { Request, Response, NextFunction } from "express";
import { z } from "zod";
import _ from "lodash-es";
 
const app = express();
app.use(express.json());
 
// Schema validation with Zod
const UserSchema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  role: z.enum(["admin", "user", "moderator"]),
});
 
type User = z.infer<typeof UserSchema> & { id: string };
 
const users: User[] = [];
 
// POST /users - Create user
app.post("/users", (req: Request, res: Response) => {
  const result = UserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      errors: result.error.issues.map((i) => ({
        path: i.path.join("."),
        message: i.message,
      })),
    });
  }
 
  const user: User = {
    id: crypto.randomUUID(),
    ...result.data,
  };
  users.push(user);
  res.status(201).json(user);
});
 
// GET /users - List users with lodash filtering
app.get("/users", (req: Request, res: Response) => {
  const { role, sort } = req.query;
 
  let filtered = users;
  if (role) {
    filtered = _.filter(users, { role: role as string });
  }
  if (sort === "name") {
    filtered = _.sortBy(filtered, "name");
  }
 
  res.json({
    total: filtered.length,
    users: filtered,
  });
});
 
// Error handling middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error("Unhandled error:", err.message);
  res.status(500).json({ error: "Internal server error" });
});
 
app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

Run with Deno:

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

Using Deno KV with npm Packages

// kv-store.ts - Combining Deno's KV with npm packages
import { z } from "zod";
 
const kv = await Deno.openKv();
 
const TodoSchema = z.object({
  title: z.string().min(1).max(200),
  completed: z.boolean().default(false),
  createdAt: z.string().datetime().default(() => new Date().toISOString()),
});
 
type Todo = z.infer<typeof TodoSchema> & { id: string };
 
async function createTodo(input: unknown): Promise<Todo> {
  const data = TodoSchema.parse(input);
  const todo: Todo = { id: crypto.randomUUID(), ...data };
  await kv.set(["todos", todo.id], todo);
  return todo;
}
 
async function listTodos(): Promise<Todo[]> {
  const entries = kv.list<Todo>({ prefix: ["todos"] });
  const todos: Todo[] = [];
  for await (const entry of entries) {
    todos.push(entry.value);
  }
  return todos;
}
 
async function toggleTodo(id: string): Promise<Todo | null> {
  const entry = await kv.get<Todo>(["todos", id]);
  if (!entry.value) return null;
 
  const updated = { ...entry.value, completed: !entry.value.completed };
  await kv.set(["todos", id], updated);
  return updated;
}
 
// Usage
const todo = await createTodo({ title: "Learn Deno 2.0" });
console.log("Created:", todo);
const all = await listTodos();
console.log("All todos:", all);
deno run --allow-read --allow-env --allow-write kv-store.ts

Node.js compatibility workflow

Real-World Use Cases

Use Case 1: Migrating an Express REST API

A team has a production Express API with 50 endpoints, PostgreSQL connections via pg, JWT authentication, and Winston logging. Migration involves:

// Before (Node.js)
const express = require('express');
const { Pool } = require('pg');
const jwt = require('jsonwebtoken');
const winston = require('winston');
 
// After (Deno 2.0) - Same imports work
import express from "npm:express";
import pg from "npm:pg";
import jwt from "npm:jsonwebtoken";
import winston from "npm:winston";

The migration required zero code changes to business logic. The only modifications were switching from require() to import syntax and granting permissions via CLI flags.

Use Case 2: Using Prisma ORM with Deno

Prisma works with Deno 2.0 out of the box:

// prisma/schema.prisma
// Standard Prisma schema works unchanged
 
// main.ts
import { PrismaClient } from "@prisma/client";
 
const prisma = new PrismaClient();
 
async function main() {
  const users = await prisma.user.findMany({
    where: { active: true },
    include: { posts: true },
  });
  console.log(`Found ${users.length} active users`);
}
 
main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());

Use Case 3: Building a CLI Tool with npm Packages

// cli.ts
import { Command } from "npm:commander";
import chalk from "npm:chalk";
import ora from "npm:ora";
 
const program = new Command();
 
program
  .name("data-tool")
  .description("CLI for data processing")
  .version("1.0.0");
 
program
  .command("process <file>")
  .description("Process a CSV file")
  .option("-o, --output <dir>", "Output directory", "./output")
  .action(async (file: string, options: { output: string }) => {
    const spinner = ora(`Processing ${file}`).start();
    try {
      const data = await Deno.readTextFile(file);
      const lines = data.split("\n").length;
      spinner.succeed(chalk.green(`Processed ${lines} lines`));
    } catch (err) {
      spinner.fail(chalk.red(`Failed: ${err.message}`));
    }
  });
 
program.parse();
deno run --allow-read --allow-write cli.ts process data.csv -o ./results

Best Practices for Production

  1. Use package.json over npm: specifiers: While npm: specifiers work inline, package.json provides version pinning, lockfile support, and compatibility with existing tooling. This makes your project accessible to contributors who may prefer Node.js.

  2. Pin dependency versions explicitly: Avoid version ranges that could resolve differently across environments. Use exact versions ("express": "4.21.0") and commit your lockfile.

  3. Test Node.js APIs in isolation: Before migrating a production system, write integration tests that exercise every Node.js API your code uses—file system operations, child processes, crypto functions, and stream handling. Some APIs have subtle behavioral differences.

  4. Use granular permission flags: Deno's security model is a major advantage. Instead of --allow-all, specify exact permissions: --allow-net=api.example.com:443 --allow-read=./data --allow-env=DATABASE_URL.

  5. Profile startup time: Deno's module resolution differs from Node.js. Large dependency trees with many node_modules entries can slow cold starts. Use DENO_DIR caching and consider bundling for production.

  6. Monitor the compatibility matrix: The Deno team publishes a Node.js compatibility status page. Track the APIs you depend on and subscribe to updates.

  7. Migrate incrementally: Start by running your Node.js code on Deno without changes. Then progressively adopt Deno-native APIs (Deno.readFile, Deno.Command, Deno.Kv) where they offer advantages.

  8. Use deno.json for configuration: Define tasks, import maps, and compiler options in deno.json instead of scattered config files. This unifies your toolchain configuration.

Common Pitfalls and Solutions

PitfallImpactSolution
Assuming all npm packages work perfectlyRuntime errors on unimplemented APIsTest critical dependencies before migration; check Deno's compatibility matrix
Using Node.js globals without importsReferenceError: process is not definedImport explicitly: import process from "node:process"
Relying on __dirname in ESMReferenceError: __dirname is not definedUse import.meta.url with fileURLToPath()
CommonJS circular dependency edge casesPartial module objects in circular requiresTest circular dependencies thoroughly; restructure if possible
Native Node addons (.node files)Load failuresReplace with WebAssembly alternatives or pure JS implementations
node_modules size and startup costSlower cold startsUse deno install with --node-modules-dir=auto to control when node_modules is created
process.exit() behavior differencesSubtle timing issuesUse Deno.exit() or handle cleanup through signal listeners

Performance Optimization

// Use Deno's native HTTP server for maximum throughput
import { serve } from "https://deno.land/std@0.224.0/http/server.ts";
 
// Benchmark: Deno's native server vs Express
// Deno native: ~80,000 req/s
// Express via npm:compat: ~15,000 req/s
 
// For high-throughput endpoints, use Deno.serve directly
Deno.serve({ port: 3000 }, (req: Request) => {
  const url = new URL(req.url);
 
  if (url.pathname === "/health") {
    return new Response("OK", { status: 200 });
  }
 
  if (url.pathname === "/api/data") {
    return Response.json({ timestamp: Date.now() });
  }
 
  return new Response("Not Found", { status: 404 });
});
// Enable V8 optimizations
// deno.json
{
  "compilerOptions": {
    "target": "ESNext"
  },
  "tasks": {
    "start": "deno run --v8-flags=--turbo-fast-api-calls server.ts"
  }
}
 
// Profile with Deno's built-in tools
deno run --prof server.ts
deno tooling v8-prof isolate-*.log > profile.txt

Comparison with Alternatives

FeatureDeno 2.0Node.js 22Bun 1.x
npm CompatibilityExcellentNativeGood (some gaps)
TypeScript SupportNative, zero configRequires tsx/ts-nodeNative
Security ModelPermissions-basedNoneNone
Built-in ToolsTest, fmt, lint, benchNoneTest, bundler
Package Managernpm compatible + deno installnpm/yarn/pnpmbun install
Cold Start~25ms~45ms~8ms
HTTP Performance~80k req/s~70k req/s~100k req/s
Ecosystem Sizenpm via compatLargest nativenpm via compat

Advanced Patterns

Conditional Module Resolution

// utils/platform.ts - Runtime-agnostic file operations
type Runtime = "deno" | "node" | "bun";
 
function detectRuntime(): Runtime {
  if ("Deno" in globalThis) return "deno";
  if ("Bun" in globalThis) return "bun";
  return "node";
}
 
export async function readFile(path: string): Promise<string> {
  const runtime = detectRuntime();
 
  switch (runtime) {
    case "deno":
      return await Deno.readTextFile(path);
    case "bun":
      const bunFile = globalThis.Bun.file(path);
      return await bunFile.text();
    case "node":
      const fs = await import("node:fs/promises");
      return await fs.readFile(path, "utf-8");
  }
}
 
export async function writeFile(path: string, content: string): Promise<void> {
  const runtime = detectRuntime();
 
  switch (runtime) {
    case "deno":
      await Deno.writeTextFile(path, content);
      break;
    case "bun":
      await globalThis.Bun.write(path, content);
      break;
    case "node":
      const fs = await import("node:fs/promises");
      await fs.writeFile(path, content, "utf-8");
      break;
  }
}

Using Deno's Permission System with Express Middleware

// middleware/permissions.ts
import { Request, Response, NextFunction } from "express";
 
// Deno's permission model can enforce network access at the runtime level
// Combine with Express middleware for defense in depth
 
export function requirePermission(permission: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    // Check Deno permissions programmatically
    const status = await Deno.permissions.query({
      name: "net",
      host: req.hostname,
    });
 
    if (status.state !== "granted") {
      return res.status(403).json({
        error: "Insufficient permissions",
        required: permission,
      });
    }
 
    next();
  };
}

Testing Strategies

// tests/api.test.ts
import { assertEquals, assertExists } from "https://deno.land/std/assert/mod.ts";
 
// Test Express routes using Deno's built-in test runner
Deno.test("POST /users creates a user", async () => {
  const response = await fetch("http://localhost:3000/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      name: "Alice",
      email: "alice@example.com",
      role: "admin",
    }),
  });
 
  assertEquals(response.status, 201);
  const user = await response.json();
  assertExists(user.id);
  assertEquals(user.name, "Alice");
});
 
Deno.test("POST /users rejects invalid email", async () => {
  const response = await fetch("http://localhost:3000/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      name: "Bob",
      email: "not-an-email",
      role: "user",
    }),
  });
 
  assertEquals(response.status, 400);
  const body = await response.json();
  assertExists(body.errors);
});
 
// Run with: deno test --allow-net --allow-read --allow-env

Future Outlook

The Deno team has committed to reaching 100% Node.js API compatibility. Key roadmap items include:

  • Full child_process.fork() support for cluster-based scaling
  • Worker threads parity with Node.js worker_threads module
  • Improved native addon support through NAPI-RS compatibility
  • WinterCG standardization ensuring consistent APIs across Deno, Node.js, and Bun

The convergence is accelerating. Libraries like Hono, Drizzle, and tRPC already work across all three runtimes without modification. The goal is a JavaScript ecosystem where the runtime is a deployment choice, not an architectural constraint.

Conclusion

Deno 2.0's Node.js compatibility represents the most significant shift in the JavaScript runtime landscape since the introduction of ES modules. By supporting package.json, npm packages, and CommonJS modules, Deno has eliminated the primary adoption barrier while maintaining its distinctive advantages: security-first permissions, native TypeScript, and a unified toolchain.

Key takeaways for developers considering Deno 2.0:

  1. Migration is incremental — run existing Node.js code on Deno, then adopt Deno-native features over time
  2. npm compatibility is production-ready — Express, Prisma, Drizzle, and most popular packages work without modification
  3. The permission model adds real security — granular access control prevents supply chain attacks and limits blast radius
  4. Cross-runtime development is practical — write code that works on Deno, Node.js, and Bun with conditional imports
  5. The ecosystem is converging — WinterCG standards are reducing the cost of runtime switching to near zero

The future of server-side JavaScript is polyglot at the runtime level. Deno 2.0 ensures you can choose the right tool for the job without abandoning the npm ecosystem that makes JavaScript development productive.