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."