<!-- Canonical: https://lucioduran.com/blog/deno-2-node-compat-fresh-framework
Language: en
Content-SHA256: 3bc17f9b64a009a599c775b689eb76f3b078be753e1d166d8bc70b8d56425f3b
-->

JavaScript Runtimes

# Deno 2: Full Node Compatibility, Fresh 2.0, and Comparative Benchmarks Against Node 22 and Bun

2025-11-098 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/deno-2-node-compat-fresh-framework>)

## The Node.js Compatibility Layer

[Deno](<https://deno.com/>) 2's compatibility with Node.js is built on three pillars:

### 1. npm Specifiers

You can import any npm package directly:

```typescript
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.

```bash
# 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:

```typescript
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:

```json
{
 "name": "my-app",
 "scripts": {
 "dev": "deno task dev",
 "build": "deno task build"
 },
 "dependencies": {
 "hono": "^4.0.0",
 "drizzle-orm": "^0.30.0"
 }
}
```

```bash
# 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](<https://fresh.deno.dev/>) 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:

```tsx
// 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>
 );
}
```

```tsx
// 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:

```tsx
// 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."

## Frequently asked questions

### Can I use any npm package with Deno 2?

Most packages work out of the box with npm: specifiers. Packages that rely on native addons (node-gyp), postinstall scripts, or Node.js-specific internal APIs may need the --node-modules-dir flag or may not work at all. In practice, about 95% of the top 1000 npm packages work without issues.

### Is Deno faster than Node.js?

It depends on the workload. For HTTP serving, Deno's Hyper-based server is 15-20% faster. For CPU-bound work, they're essentially identical (both use V8). For startup time, Deno is slower due to TypeScript type-checking (unless using --no-check). Bun is faster than both for startup.

### Should I migrate my Node.js project to Deno 2?

Only if you value Deno's built-in TypeScript, permissions model, or Deno Deploy's edge runtime. For existing Node.js projects with complex build pipelines, the migration cost rarely justifies the benefits. For new projects, Deno 2 is a serious contender.

## Tips

1. **npm specifier resolution:** Deno 2 resolves ` npm: ` specifiers through a global cache at ` $DENO_DIR/npm/ `, not ` node_modules/ `. If a package uses postinstall scripts (native addons), you need ` --node-modules-dir ` flag to create a local node\_modules. Check with ` deno info npm:package-name ` to see the resolution graph before debugging import issues.
2. **Fresh 2.0 partial hydration:** Fresh uses Preact signals for island reactivity. Wrap interactive components with ` island() `, everything outside islands ships zero JS to the client. The key insight: use ` <Partial> ` components for updating DOM sections without full page navigation. This gives you SPA-like transitions with MPA architecture.
3. **V8 isolate reuse in Deno Deploy:** Deno Deploy keeps V8 isolates warm between requests. First request to a cold isolate takes ~50ms, subsequent requests reuse the isolate at ~1ms overhead. Structure your module-level code to run once (database connections, config parsing) since it persists across requests within the same isolate lifecycle.
4. **Deno.serve() vs node:http performance:** ` Deno.serve() ` uses Hyper (Rust) under the hood, bypassing V8 for TCP handling. For the HTTP layer specifically, it benchmarks 15-20% faster than Node's ` node:http `. But if your handler is CPU-bound (template rendering, JSON serialization), the runtime difference disappears because both spend 95%+ time in V8.

- deno
- node-js
- bun
- fresh-framework
- javascript-runtimes
- typescript
- performance

[Back to Blog](<https://lucioduran.com/blog>)
