La Capa de Compatibilidad con Node.js

La compatibilidad de Deno 2 con Node.js se construye sobre tres pilares:

1. npm Specifiers

Es posible importar cualquier paquete npm directamente:

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

Por debajo, Deno descarga el paquete a un cache global (~/.cache/deno/npm/) y reescribe el import map en tiempo de resolución. La diferencia clave con Node: no hay carpeta node_modules por defecto. Deno resuelve dependencias desde el cache global, deduplicando entre proyectos.

Esto realmente resuelve uno de los puntos de dolor más viejos de Node. Si teners 10 proyectos que todos usan lodash@4.17.21, Node almacena 10 copias. Deno almacena una.

# Ver dónde se resuelve un paquete
$ deno info npm:express@4
local: /home/user/.cache/deno/npm/registry.npmjs.org/express/4.21.1/

2. Polyfills de Módulos Built-in de Node

Deno implementa el protocolo node: para módulos built-in:

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

La cobertura es impresionante pero no completa. En Deno 2.1, la realidad es así:

Totalmente implementados: 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

Parcialmente implementados: node:cluster (sin sticky sessions),
 node:dgram (solo UDP básico),
 node:dns (sin resolveNs/resolveSrv),
 node:vm (sin runInContext)

No implementados: node:v8 (heap snapshots de V8),
 node:trace_events,
 node:perf_hooks (parcial)

Para la mayoría de las aplicaciones web, la lista de completamente implementados cubre todo lo que se necesita. Donde surgen problemas es en paquetes nicho que dependen de node:vm para sandboxing o node:v8 para análisis de heap.

3. Soporte de package.json

Deno 2 lee package.json y respeta sus campos:

{
 "name": "my-app",
 "scripts": {
 "dev": "deno task dev",
 "build": "deno task build"
 },
 "dependencies": {
 "hono": "^4.0.0",
 "drizzle-orm": "^0.30.0"
 }
}
# Deno lee package.json e instala dependencias
$ deno install
# O simplemente ejecutar, Deno auto-instala en el primer uso
$ deno run -A server.ts

Esta es la feature que hace la migración realista. Es posible tomar un proyecto Node.js existente, apuntar Deno a él, y la mayoría de las cosas simplemente funcionan.

Fresh 2.0: El Framework que Hace Islands Bien

Fresh es el framework web full-stack de Deno, y la versión 2.0 es un replanteamiento significativo. La idea core no cambió, rendering server-side por defecto con "islands" de interactividad, pero la implementación es mucho más refinada.

Cómo Funcionan los Islands

SPA Tradicional:
 Servidor renderiza HTML → Cliente descarga toda la app React →
 Hidrata todo → La página se vuelve interactiva

Fresh Islands:
 Servidor renderiza HTML → Cliente descarga SOLO JS de islands →
 Hidrata SOLO islands → Resto de la página es HTML estático (cero JS)

A continuación se muestra cómo se ve en la práctica:

// routes/products/[id].tsx, Ruta renderizada en servidor (cero JS cliente)
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";

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">
 {/* Esto se renderiza en el servidor. Cero JS al cliente. */}
 <ProductDetails product={data} />

 {/* Esto es un island. Solo el JS de este componente se envía. */}
 <AddToCartIsland productId={data.id} price={data.price} />

 {/* Otro island. Se carga independientemente. */}
 <ReviewsIsland productId={data.id} />
 </div>
 );
}
// islands/AddToCart.tsx, Esto envía JS al cliente
import { useSignal } from "@preact/signals";

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"
 >
 Agregar al Carrito, ${(price * quantity.value).toFixed(2)}
 </button>
 </div>
 );
}

El componente ProductDetails envía cero JavaScript. El island AddToCart envía quizás 3KB de JS (runtime de señales Preact + componente). Para una página de producto que podría ser 50KB de HTML, se está enviando 3KB de JS en vez de los 150KB+ que una SPA React requeriría.

Partials: Navegación SPA Sin SPAs

Fresh 2.0 introdujo Partials, una forma de actualizar secciones de la página sin navegación completa:

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>
 );
}

Clickear un link con atributo f-partial fetches el nuevo fragmento HTML del servidor y intercambia solo esa sección del DOM. Sin librería de routing client-side. Sin state management de React. Solo HTML-over-the-wire con un runtime minúsculo (~2KB).

Benchmarks: Análisis Contextual

Este es el punto donde una perspectiva contraria es justificada. Todos benchmarkean runtimes con servidores HTTP de "hello world". Ese es benchmarks son inútiles para aplicaciones del mundo real. Esto es lo que realmente importa.

Setup de Tests

Todos los tests en la misma máquina (AMD Ryzen 9 7950X, 32GB RAM, Ubuntu 24.04):

  • Node 22.11.0 con Fastify 5.0
  • Deno 2.1.4 con Deno.serve()
  • Bun 1.1.38 con 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 supera a Node por ~19%. Bun supera a Deno por ~10%. Estos números coinciden con el marketing. Pero la cosa es esta, este benchmark está midiendo la implementación del servidor HTTP, no el runtime.

API JSON con Acceso a Base de Datos

Más realista, fetch de SQLite, serializar JSON, retornar:

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

La brecha se achica dramáticamente. Ahora estamos pasando la mayor parte del tiempo en V8 (los tres lo usan) y SQLite. La capa HTTP del runtime apenas importa.

Server-Side Rendering (Preact/JSX)

Renderizar una página de producto con 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

Esencialmente idénticos. SSR es enteramente CPU-bound en V8. El runtime no importa.

Tiempo de Startup

Este es el punto donde las diferencias son marcadas:

Node 22 (script vacío): 35ms
Node 22 (servidor Fastify): 120ms
Deno 2.1 (script vacío): 45ms
Deno 2.1 (con type-check): 280ms
Deno 2.1 (--no-check): 50ms
Bun 1.1 (script vacío): 8ms
Bun 1.1 (servidor Elysia): 22ms

Bun destruye absolutamente a Node y Deno en startup. Para funciones serverless donde el cold start importa, esta es la característica más destacada de Bun.

Uso de Memoria

Servidor HTTP idle:

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

Cuándo Elegir Qué

Basándose en el uso productivo de los tres runtimes en diferentes proyectos, esta es una evaluación honesta:

Elegir Deno 2 cuando:

  • Se desea TypeScript built-in sin paso de build
  • La seguridad importa (el modelo de permise es es único y valioso)
  • Se está desplegando a Deno Deploy (su runtime edge es excelente)
  • Se está arrancando un proyecto nuevo y se desea la arquitectura de islands de Fresh
  • Se desea una sola herramienta para formateo, linting, testing, y ejecución

Elegir Node 22 cuando:

  • Teners un codebase Node.js existente
  • Se depende de addons nativos (node-gyp)
  • Tu equipo conoce Node y el ecosistema profundamente
  • Se necesita la compatibilidad de paquetes más amplia posible

Elegir Bun cuando:

  • El tiempo de cold start es crítico (serverless, herramientas CLI)
  • Se desea la experiencia de desarrollo más rápida posible
  • Se necesita SQLite built-in
  • Se está dispuesto a aceptar un runtime menos maduro por ganancias de velocidad

La verdad incómoda que ni el equipo de marketing de Deno ni el de Bun te van a decir es esta: para la mayoría de las aplicaciones web, el runtime no importa. Tu cuello de botella son las queries a la base de datos, el I/O de red, y la lógica de negocio. La diferencia entre 128K y 167K requests por segundo en un benchmark de hello world nunca va a ser relevante en producción.

Lo que sí importa es la experiencia de desarrollo, la compatibilidad del ecosistema, y la historia de deployment. En esas dimensiones, Deno 2 dio un salto masivo hacia adelante. Ya no te está pidiendo que abandones el ecosistema npm, te está dejando usarlo mientras ofrece algo mejor para código nuevo.

Ese es un pitch mucho más fuerte que "somos X% más rápidos sirviendo respuestas HTTP vacías."