The Node.js Compatibility Layer

Deno 2's compatibility with Node.js is built on three pillars:

1. npm Specifiers

You can import any npm package directly:

import express from "npm:express@4";
import { PrismaClient } from "npm:@prisma/client";
import chalk from "npm:chalk@5";

Under the hood, Deno downloads the package to a global cache (~/.cache/deno/npm/) and rewrites the import map at resolution time. The key difference from Node: there's no node_modules folder by default. Deno resolves dependencies from the global cache, deduplicating across projects.

This actually solves one of Node's longest-standing pain points. If you have 10 projects that all use lodash@4.17.21, Node stores 10 copies. Deno stores one.

# See where a package resolves to
$ deno info npm:express@4
local: /home/user/.cache/deno/npm/registry.npmjs.org/express/4.21.1/

2. Node Built-in Module Polyfills

Deno implements the node: protocol for built-in modules:

import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { join } from "node:path";
import { Buffer } from "node:buffer";

The coverage is impressive but not complete. As of Deno 2.1, here's the reality:

Fully implemented: node:fs, node:path, node:url, node:buffer,
 node:crypto, node:stream, node:events,
 node:util, node:os, node:process, node:http,
 node:https, node:net, node:tls, node:zlib,
 node:assert, node:child_process, node:worker_threads

Partially implemented: node:cluster (no sticky sessions),
 node:dgram (basic UDP only),
 node:dns (no resolveNs/resolveSrv),
 node:vm (no runInContext)

Not implemented: node:v8 (V8 heap snapshots),
 node:trace_events,
 node:perf_hooks (partial)

For most web applications, the fully implemented list covers everything you need. Where trouble arises is niche packages that depend on node:vm for sandboxing or node:v8 for heap analysis.

3. package.json Support

Deno 2 reads package.json and respects its fields:

{
 "name": "my-app",
 "scripts": {
 "dev": "deno task dev",
 "build": "deno task build"
 },
 "dependencies": {
 "hono": "^4.0.0",
 "drizzle-orm": "^0.30.0"
 }
}
# Deno reads package.json and installs dependencies
$ deno install
# Or just run, Deno auto-installs on first use
$ deno run -A server.ts

This is the feature that makes migration realistic. You can take an existing Node.js project, point Deno at it, and most things just work. The migration path is incremental: start running with Deno, fix compatibility issues one at a time, then gradually adopt Deno-native APIs where they make sense.

Fresh 2.0: The Framework That Gets Islands Right

Fresh is Deno's full-stack web framework, and version 2.0 is a significant rethinking. The core idea hasn't changed, server-side rendering by default with "islands" of interactivity, but the implementation is much more refined.

How Islands Work

Traditional SPA:
 Server renders HTML → Client downloads entire React app →
 Hydrates everything → Page becomes interactive

Fresh Islands:
 Server renders HTML → Client downloads ONLY island JS →
 Hydrates ONLY islands → Rest of page is static HTML (zero JS)

The following shows what this looks like in practice:

// routes/products/[id].tsx, Server-rendered route (zero client JS)
import { Handlers, PageProps } from "$fresh/server.ts";
import { ProductDetails } from "../components/ProductDetails.tsx";
import AddToCartIsland from "../islands/AddToCart.tsx";
import ReviewsIsland from "../islands/Reviews.tsx";

interface Product {
 id: string;
 name: string;
 price: number;
 description: string;
}

export const handler: Handlers<Product> = {
 async GET(_req, ctx) {
 const product = await db.products.findUnique({
 where: { id: ctx.params.id },
 });
 if (!product) return ctx.renderNotFound();
 return ctx.render(product);
 },
};

export default function ProductPage({ data }: PageProps<Product>) {
 return (
 <div class="max-w-4xl mx-auto">
 {/* This renders on the server. Zero JS sent to client. */}
 <ProductDetails product={data} />

 {/* This is an island. Only this component's JS is sent. */}
 <AddToCartIsland productId={data.id} price={data.price} />

 {/* Another island. Loaded independently. */}
 <ReviewsIsland productId={data.id} />
 </div>
 );
}
// islands/AddToCart.tsx, This ships JS to the client
import { useSignal } from "@preact/signals";

interface Props {
 productId: string;
 price: number;
}

export default function AddToCart({ productId, price }: Props) {
 const quantity = useSignal(1);
 const loading = useSignal(false);

 async function handleAddToCart() {
 loading.value = true;
 await fetch("/api/cart", {
 method: "POST",
 body: JSON.stringify({ productId, quantity: quantity.value }),
 });
 loading.value = false;
 }

 return (
 <div class="flex items-center gap-4">
 <select
 value={quantity.value}
 onChange={(e) => quantity.value = Number(e.currentTarget.value)}
 >
 {[1, 2, 3, 4, 5].map((n) => <option value={n}>{n}</option>)}
 </select>
 <button
 onClick={handleAddToCart}
 disabled={loading.value}
 class="bg-blue-600 text-white px-4 py-2 rounded"
 >
 Add to Cart, ${(price * quantity.value).toFixed(2)}
 </button>
 </div>
 );
}

The ProductDetails component sends zero JavaScript. The AddToCart island sends maybe 3KB of JS (Preact signals runtime + component). For a product page that might be 50KB of HTML, you're sending 3KB of JS instead of the 150KB+ that a React SPA would require.

Partials: SPA Navigation Without SPAs

Fresh 2.0 introduced Partials, a way to update sections of the page without full navigation:

// components/ProductList.tsx
import { Partial } from "$fresh/runtime.ts";

export function ProductList({ products, page }) {
 return (
 <Partial name="product-list">
 <div class="grid grid-cols-3 gap-4">
 {products.map((p) => (
 <a href={`/products?page=${page}`} f-partial="/partials/products">
 <ProductCard product={p} />
 </a>
 ))}
 </div>
 <Pagination page={page} />
 </Partial>
 );
}

Clicking a link with f-partial attribute fetches the new HTML fragment from the server and swaps just that section of the DOM. No client-side routing library. No React state management. Just HTML-over-the-wire with a tiny (~2KB) runtime.

Benchmarks: Contextual Analysis

Runtime benchmarks runtimes with "hello world" HTTP servers. Those benchmarks are useless for real-world applications. Here is what actually matters.

Test Setup

All tests run on the same machine (AMD Ryzen 9 7950X, 32GB RAM, Ubuntu 24.04):

  • Node 22.11.0 with Fastify 5.0
  • Deno 2.1.4 with Deno.serve()
  • Bun 1.1.38 with Bun.serve()

Hello World HTTP (requests/sec)

Node 22 (Fastify): 128,000 req/s
Deno 2.1 (Deno.serve): 152,000 req/s
Bun 1.1 (Bun.serve): 167,000 req/s

Deno wins against Node by ~19%. Bun wins against Deno by ~10%. These numbers match the marketing. However, this benchmark is measuring the HTTP server implementation, not the runtime. Deno uses Hyper (Rust), Bun uses its own C/Zig implementation, Node uses libuv+llhttp.

JSON API with Database Access

More realistic, fetch from SQLite, serialize JSON, return:

Node 22 (Fastify + better-sqlite3): 24,500 req/s
Deno 2.1 (Deno.serve + deno-sqlite): 22,800 req/s
Bun 1.1 (Bun.serve + bun:sqlite): 26,100 req/s

The gap shrinks dramatically. Now most time is spent in V8 (all three use it) and SQLite. The runtime's HTTP layer barely matters.

Server-Side Rendering (Preact/JSX)

Render a product page with 50 items:

Node 22 (Fastify + preact-render-to-string): 3,800 req/s
Deno 2.1 (Fresh, SSR): 3,650 req/s
Bun 1.1 (Elysia + preact-render-to-string): 3,900 req/s

Essentially identical. SSR is entirely CPU-bound in V8. The runtime doesn't matter.

Startup Time

This is where the differences are stark:

Node 22 (empty script): 35ms
Node 22 (Fastify server): 120ms
Deno 2.1 (empty script): 45ms
Deno 2.1 (with type-check): 280ms
Deno 2.1 (--no-check): 50ms
Bun 1.1 (empty script): 8ms
Bun 1.1 (Elysia server): 22ms

Bun absolutely destroys both Node and Deno on startup. For serverless functions where cold start matters, this is Bun's killer feature. Deno's TypeScript type-checking adds ~230ms, use --no-check in production if startup time matters.

Memory Usage

Idle HTTP server:

Node 22 (Fastify): 45MB RSS
Deno 2.1 (Deno.serve): 38MB RSS
Bun 1.1 (Bun.serve): 32MB RSS

Under load (10,000 concurrent connections):

Node 22 (Fastify): 210MB RSS
Deno 2.1 (Deno.serve): 180MB RSS
Bun 1.1 (Bun.serve): 165MB RSS

Deno is slightly leaner than Node, likely because it doesn't load the full Node.js standard library. Bun's custom allocator gives it a consistent memory advantage.

When to Choose What

Based on production use of all three runtimes across different projects, here is an honest assessment:

Choose Deno 2 when:

  • You want built-in TypeScript without a build step
  • Security matters (permissions model is unique and valuable)
  • You're deploying to Deno Deploy (their edge runtime is excellent)
  • You're starting a new project and want Fresh's island architecture
  • You want a single tool for formatting, linting, testing, and running

Choose Node 22 when:

  • You have an existing Node.js codebase
  • You depend on native addons (node-gyp)
  • Your team knows Node and the ecosystem deeply
  • You need the broadest possible package compatibility
  • You're using a Node-specific framework (NestJS, etc.)

Choose Bun when:

  • Cold start time is critical (serverless, CLI tools)
  • You want the fastest possible development experience
  • You need built-in SQLite
  • You're willing to accept a less mature runtime for speed gains

The uncomfortable truth that neither the Deno nor the Bun marketing team will tell anyone: for most web applications, the runtime doesn't matter. Your bottleneck is database queries, network I/O, and business logic. The difference between 128K and 167K requests per second on a hello world benchmark will never be relevant in production.

What does matter is developer experience, ecosystem compatibility, and deployment story. On those dimensions, Deno 2 made a massive leap forward. It's no longer asking you to abandon the npm ecosystem, it's letting you use it while offering something better for new code.

That's a much stronger pitch than "we're X% faster at serving empty HTTP responses."