Empecemos con honestidad: migré a Deno 2.0 porque me lo pidieron en una retro de equipo y yo, con toda la confianza del mundo, dije “dos semanas máximo”. Spoiler: fueron cuatro semanas, un incidente en producción a las 11pm de un miércoles, y una cantidad de tabs de GitHub Issues que prefiero no recordar.
Trabajo en un equipo de seis personas construyendo APIs para una plataforma de analítica de contenido. Tres microservicios en Node.js 22, todos en TypeScript, todos usando ESM, con un montón de dependencias de npm que llevaban años ahí sin que nadie los tocara. El candidato perfecto para la migración, en teoría.
Tres Microservicios, Cuatro Semanas, y un Miércoles Muy Complicado
Elegí empezar con el servicio más pequeño: un worker que procesa webhooks entrantes, transforma los payloads y los encola en Redis. Unas 800 líneas de TypeScript, cuatro dependencias directas en npm. Pensé que sería el caso ideal para validar el proceso antes de tocar los servicios más críticos.
Lo que no calculé fue que “cuatro dependencias directas” significa, con el árbol completo, algo así como 340 paquetes. Y Deno 2.0, aunque mejoró enormemente la compatibilidad con npm respecto a versiones anteriores, sigue teniendo sus opiniones sobre ciertas cosas.
La migración inicial fue sorprendentemente fluida. Cambias el package.json por un deno.json, reemplazas las importaciones de npm con el prefijo npm:, y listo. En teoría. Esto funciona bien:
// Antes (Node.js)
import { Redis } from "ioredis";
import { z } from "zod";
// Después (Deno 2.0)
import { Redis } from "npm:[email protected]";
import { z } from "npm:[email protected]";
Aquí empezó lo interesante. Deno 2.0 con "nodeModulesDir": "auto" resuelve la mayoría de los paquetes sin problema. Pero ioredis usaba internamente node:tls con opciones que Deno maneja de forma ligeramente distinta, y el resultado era que la conexión se establecía, enviabas un comando, y a los 90 segundos exactos el socket se cerraba silenciosamente. Sin error. Sin log. Nada.
Lo encontré porque vi que mis métricas de latencia tenían spikes cada 90 segundos exactos — ese patrón tan regular fue la pista. Abrí el issue #24891 en el repositorio de Deno y resultó que ya estaba reportado desde noviembre de 2024. La solución: pin de [email protected] que parcheó el comportamiento, y configurar keepAlive: true explícitamente en el cliente.
Esto lo empujé un miércoles a las 7pm. El error volvió a las 11pm porque en staging no se nota — el volumen es demasiado bajo. Me enteré por un alert de Datadog.
La Compatibilidad con npm en 2026: Mejor, Pero No Perfecta
Seré directo porque he visto muchos posts que pintan esto de color de rosa.
Deno 2.x mejoró mucho la compatibilidad con el ecosistema npm. La mayoría de paquetes que no dependen de APIs nativas de Node.js funcionan sin cambios: zod, date-fns, lodash, fastify (sí, fastify en Deno) — sin problemas. Pero hay categorías donde las cosas se complican.
Paquetes que usan __dirname o __filename son los primeros candidatos a romper. Deno los polyfilla, pero si el paquete hace algo creativo con esas rutas para cargar archivos relativos, el comportamiento puede diferir. Me pasó con un SDK interno que cargaba templates desde el sistema de archivos — tres horas depurando algo que no debería haber tardado más de veinte minutos.
Después están los paquetes con addons nativos o binarios de C++. sharp para procesamiento de imágenes funciona vía npm:sharp, pero el tiempo de instalación en CI subió de 45 segundos a 3 minutos en nuestro caso porque Deno no cachea los binarios compilados igual que npm.
El que más me sorprendió: algunos paquetes que detectan el entorno hacen typeof process !== 'undefined' para saber si están en Node — y Deno 2.0 expone un objeto process para compatibilidad, así que eso está bien. El problema viene cuando el paquete luego consulta process.versions.node y espera una versión específica. Deno devuelve un valor emulado que no siempre cuadra con las expectativas internas del paquete.
// Esto puede romper paquetes que verifican la versión de Node
console.log(process.versions.node); // En Deno: "22.0.0" (emulado)
console.log(Deno.version.deno); // "2.2.4"
// Para detectar si estás en Deno:
const isDenoRuntime = typeof Deno !== "undefined";
JSR (el registro de paquetes de Deno) creció bastante en 2025. A principios de 2026 hay paquetes como @std/http, @std/async, @std/encoding que son de primera clase y funcionan perfectamente. Si puedes sustituir dependencias npm por equivalentes de JSR, la experiencia mejora notablemente.
El Sistema de Permisos: Pesadilla Operacional o Ventaja Real
Cuando empecé a migrar pensé que el sistema de permisos iba a ser un dolor de cabeza. Que terminaría dando --allow-all en producción porque quién sabe qué permisos necesita ioredis internamente, o cualquier otro paquete npm.
Me equivoqué, y de forma interesante.
El proceso de descubrir los permisos que necesita tu aplicación es incómodo al principio — ejecutas sin permisos y Deno te dice exactamente qué necesita — pero esa incomodidad te da algo valioso: un mapa de lo que tu aplicación realmente hace. Nuestro worker de webhooks necesitaba exactamente esto:
// deno.json
{
"tasks": {
"start": "deno run --allow-net=redis.internal:6379,api.externa.com:443 --allow-env=REDIS_URL,WEBHOOK_SECRET,LOG_LEVEL --allow-read=/etc/ssl/certs src/main.ts"
},
"nodeModulesDir": "auto",
"imports": {
"@std/async": "jsr:@std/async@^1.0.0"
}
}
Eso es todo. Un servicio que en Node.js corría con acceso total al sistema, en Deno corre con permisos explícitos a una IP de Redis, un dominio externo, tres variables de entorno, y los certificados SSL. Si alguien introduce una dependencia que intenta leer /etc/passwd o hacer una petición a un dominio desconocido, Deno lo bloquea en runtime.
No sé si esto escala a aplicaciones con 50 dependencias y comportamientos dinámicos complejos — probablemente hay casos donde terminas en --allow-all de todas formas. Pero para servicios pequeños y bien definidos, cambió cómo pienso sobre la seguridad de lo que pongo en producción.
El Tooling Integrado: Lo Que No Calculé
Yo asumía que el formatter, linter y test runner integrados de Deno eran un nice-to-have para proyectos pequeños. Después de cuatro meses, es lo que más valoro del ecosistema — y no lo vi venir.
No porque deno fmt sea mejor que Prettier (honestamente son bastante similares en resultado). Sino porque elimina una categoría completa de discusiones en el equipo. Sin archivo .prettierrc. Sin conflictos de versión entre eslint y typescript-eslint. Sin “¿qué configuración de eslint usamos?” El standard es el runtime.
La primera semana, un compañero que lleva seis años en Node.js me preguntó cómo configurar el linter. Le respondí que no había configuración. Se quedó callado un momento y luego dijo “ah, ¿entonces funciona?” Sí, funciona.
El test runner también. deno test soporta cobertura de código nativa, watch mode, y el mismo formato de describe/it al que estás acostumbrado si vienes de Jest o Vitest:
import { assertEquals, assertRejects } from "jsr:@std/assert";
import { processWebhook } from "./webhook.ts";
Deno.test("procesa payload válido correctamente", async () => {
const payload = { event: "push", repo: "mi-repo" };
const result = await processWebhook(payload);
assertEquals(result.status, "queued");
assertEquals(result.jobId.startsWith("job_"), true);
});
Deno.test("rechaza payload sin firma", async () => {
await assertRejects(
() => processWebhook({}, { skipSignatureVerification: false }),
Error,
"Invalid signature"
);
});
Sin instalar nada. Sin configurar nada. deno test y ya.
Deno Deploy merece una mención aparte: lo usé para un cuarto servicio, este sí desde cero, y el workflow de deploy es genuinamente rápido — push al repo, deploy en menos de 30 segundos, edge computing global. Pero tiene sus propias restricciones (algunas APIs de filesystem no están disponibles, el modelo de permisos cambia), así que no es directamente equivalente a correr Deno en tu propio servidor.
Cuatro Meses Después: El Veredicto
Los tres microservicios están en producción. Funcionan. No extraño Node.js en ninguno de ellos.
Pero te sería deshonesto si dijera que la migración fue solo positiva. El tiempo real fue el doble de lo estimado. Las incompatibilidades de npm son reales y no siempre están documentadas — a veces simplemente tienes que probar y ver qué explota. El ecosistema de JSR, aunque crece, todavía no tiene la cobertura de npm para casos de uso especializados.
Lo que sí cambió: el tiempo de setup de proyectos nuevos bajó bastante. El onboarding de un desarrollador nuevo es más simple porque hay menos configuración que explicar. Los containers de Docker son más pequeños porque no llevamos node_modules. Y el código TypeScript se siente más limpio cuando Deno es tu target, porque usas las APIs web estándar (fetch, WebSocket, crypto) directamente, sin polyfills.
Mi recomendación concreta: si tienes un servicio Node.js con pocas dependencias npm, bien tipado en TypeScript, y estás dispuesto a invertir una o dos semanas en la migración, hazlo. Si tienes un monolito con 200 dependencias npm, código legacy sin tipos, y deadlines apretados, no lo hagas todavía. No porque Deno sea malo, sino porque la fricción de compatibilidad va a comerte vivo.
Para proyectos nuevos en 2026, ya no consideraría Node.js como primera opción por defecto. Deno ganó ese puesto en mi stack.