12k
All articles

El fin de las compilaciones duales CJS/ESM en Node.js

Node.js ya admite require(esm), así que ESM-only es la opción por defecto para muchas librerías. Cuándo dejar CJS, evitar top-level await y migrar con seguridad.

OpenReplay Team
OpenReplay Team
El fin de las compilaciones duales CJS/ESM en Node.js

A partir de junio de 2026, todas las versiones de Node.js con soporte activo pueden ejecutar require() sobre un módulo ES, lo que elimina la única razón por la que la mayoría de las bibliotecas distribuían compilaciones duales CommonJS/ESM. Para una proporción grande y creciente de paquetes, ESM-only es ahora la elección correcta por defecto. La asimetría que definió una década de problemas de empaquetado —CommonJS no podía import ni require nada del mundo ESM— ya no existe en ningún entorno de ejecución que debas seguir soportando. El mapa dual de exports, la salida paralela de tsup/unbuild, el malabarismo con declaraciones .d.cts/.d.ts: la mayor parte de esa maquinaria existe para resolver un problema que Node ya ha resuelto en su núcleo.

Este artículo presenta el argumento de 2026 que las guías de compilación dual más antiguas no pueden hacer: aquí está la cronología exacta de versiones donde murió la asimetría, qué hace realmente require(esm) y el único límite estricto que impone, además de un marco de decisión para determinar si aún necesitas una compilación CommonJS. La restricción no ha desaparecido —se ha desplazado. El nuevo contrato de compatibilidad no es “distribuye dos formatos”; es “mantén tu ruta de carga síncrona libre de await a nivel superior.”

Puntos clave

  • A partir de Node.js 25.4.0 (publicado el 19 de enero de 2026), require(esm) se marca como estable, y el mismo cambio fue retroportado a las líneas LTS activas, lo que significa que todas las versiones de Node.js con soporte activo incluyen la capacidad de ejecutar require() sobre un módulo ES.
  • require(esm) llegó por primera vez detrás de --experimental-require-module en Node 22, se habilitó sin flag en Node 23, se retroportó a LTS en v22.12.0 (3 de diciembre de 2024) y v20.19.0, y se declaró estable a finales de 2025.
  • require(esm) tiene exactamente un límite estricto: no puede cargar un módulo ES cuyo grafo utilice await a nivel superior, lo que lanza ERR_REQUIRE_ASYNC_MODULE e indica que se debe usar import() en su lugar.
  • Para un autor que publica solo ESM, el primer await a nivel superior en cualquier parte de tu grafo accesible mediante require es un cambio disruptivo para todos los consumidores CommonJS — trátalo como un cambio semver-major.
  • Si tu paquete apunta a Node 22.12+ y evita el await a nivel superior en el código que los usuarios de CJS ejecutarán con require(), distribuir solo ESM es ahora la elección correcta por defecto; mantén una compilación CJS únicamente para entornos anteriores a 20.19 o para módulos con TLA.

Por qué existían las compilaciones duales CJS/ESM

Las compilaciones duales existían porque CommonJS no podía ejecutar require() sobre un módulo ES. Los dos sistemas cargan de forma diferente: require() es síncrono y devuelve module.exports en el momento en que la llamada se completa, mientras que ESM se trataba como incondicionalmente asíncrono. Un llamador síncrono no puede esperar una carga asíncrona, por lo que require('some-esm-package') lanzaba ERR_REQUIRE_ESM. La dirección inversa siempre funcionó —ESM puede import CommonJS— lo que generó la situación asimétrica con la que los autores de bibliotecas convivieron durante años: distribuir ESM para los consumidores modernos, distribuir CommonJS para todos los que seguían usando require(), y conectar ambos a través de exports condicionales.

Esto implicaba una sobrecarga real de herramientas. Empaquetadores como tsup y unbuild emiten ambos formatos; un mapa exports en package.json enruta import hacia la entrada .mjs y require hacia la .cjs; TypeScript necesita declaraciones .d.ts y .d.cts colocadas junto al código para que ambos modos de resolución pasen la verificación de tipos. La guía de compilación dual de 2021 de Anthony Fu y el tutorial de 2023 de Mayank documentan esta maquinaria en profundidad —y ambos siguen siendo precisos en cuanto a cómo hacerlo. Simplemente están respondiendo una pregunta que, para los entornos de ejecución actuales, ya no necesita plantearse.

Las compilaciones duales también conllevaban un riesgo estructural: el riesgo del paquete dual. Cuando un grafo de dependencias carga tu paquete mediante import en un lugar y require en otro, Node puede cargar dos copias separadas —la compilación ESM y la compilación CJS— como instancias de módulo distintas. Cualquier singleton, caché, registro o comprobación con instanceof verá entonces dos estados divergentes. La compilación dual que resolvía el problema de interoperabilidad creaba silenciosamente un problema de duplicación de estado.

require(esm): las versiones exactas donde murió la asimetría

La solución surgió de la corrección de una suposición largamente mantenida. Como documentó la colaboradora del núcleo de Node, Joyee Cheung, en su blog, ESM en sí mismo no fue diseñado para ser incondicionalmente asíncrono —sino solo condicionalmente asíncrono, únicamente cuando el grafo contiene await a nivel superior— por lo que parecía natural que require() al menos soportara grafos ESM que no contuvieran await a nivel superior. Esa perspectiva hizo posible un require() síncrono de (la mayoría de) los módulos ES, y require(esm) se construyó sobre ella.

El despliegue ocurrió en etapas a lo largo de las distintas líneas de versiones. Aquí está la cronología a junio de 2026:

Línea de Node.jsEstado de require(esm)Fase de soporte (junio 2026)
18.xNunca recibió la retroportaciónEOL — debe migrar a 20+
20.xHabilitado sin flag en v20.19.0EOL 30 de abril de 2026
22.xActivo por defecto en v22.12.0 (3 dic 2024)Maintenance LTS
23.xHabilitado sin flag (no-LTS)EOL
24.xMarcado estable retroportado en v24.15.0 (15 abr 2026)Active LTS
25.xMarcado estable en v25.4.0 (19 ene 2026)EOL 1 de junio de 2026
26.xEstableCurrent

El titular: en la versión v25.4.0, el cambio “module: mark require(esm) as stable” (PR #60959) eliminó la marca experimental, y ese mismo commit fue retroportado a la línea LTS en v24.15.0. La funcionalidad había sido habilitada por defecto mucho antes de su estabilización: Node 22.12.0 fue la primera versión LTS con ella activa por defecto, y fue retroportada a Node 20 en v20.19.0. Node 18 nunca recibió la retroportación.

Según el calendario de versiones de Node.js, las líneas con soporte en junio de 2026 son 22 (Maintenance LTS), 24 (Active LTS, soporte activo hasta el 20 de octubre de 2026, luego mantenimiento de seguridad hasta el 30 de abril de 2028) y 26 (Current). Las tres están por encima del umbral de habilitación. Con Node 18 sin retroportación y Node 20 habiendo alcanzado el fin de vida el 30 de abril de 2026, la versión mínima que cualquier proyecto con soporte activo debería apuntar ya incluye require(esm).

Qué cambia require(esm) para los autores de bibliotecas

Un consumidor CommonJS en una versión actual de Node puede ahora ejecutar require() directamente sobre un paquete ESM-only. La justificación original para distribuir una compilación CJS —que los usuarios de require() quedarían excluidos de otro modo— ya no se sostiene en ningún entorno de ejecución con soporte activo. Como describe la documentación de Node.js, si el módulo ES que se está cargando cumple los requisitos, require() puede cargarlo y devolver el objeto de espacio de nombres del módulo; en este caso es similar a import() dinámico pero se ejecuta de forma síncrona y devuelve el objeto de espacio de nombres directamente.

Esto también elimina el riesgo del paquete dual. Dado que un llamador CommonJS ahora carga el módulo ES real en lugar de una copia CJS paralela, hay una sola instancia del módulo, un solo singleton, una sola caché —el problema de estado divergente que justificaba las cuidadosas compilaciones duales simplemente no surge cuando solo hay una compilación.

Un detalle de interoperabilidad es importante cuando se elimina el envoltorio CJS. require(esm) devuelve un objeto de espacio de nombres, no un valor directo, por lo que una exportación por defecto se encuentra en .default en lugar de ser el valor de retorno en sí mismo, de forma similar a los resultados devueltos por import(). Si deseas un único valor de retorno al estilo CommonJS, el módulo ES puede exportar el valor deseado usando el nombre de cadena "module.exports" para personalizar lo que require(esm) devuelve directamente.

Puedes detectar el soporte en tiempo de ejecución cuando necesitas una ruta de respaldo comprobando si process.features.require_module es true.

// Detección de funcionalidad en tiempo de ejecución — true en Node 20.19+, 22.12+, y todo 24/26.
if (process.features.require_module) {
  const lib = require("some-esm-only-package");
  // la exportación por defecto está en .default
  const fn = lib.default ?? lib;
}

El único límite: el await a nivel superior es el nuevo contrato de compatibilidad

require(esm) tiene exactamente un límite estricto: no puede cargar un módulo ES cuyo grafo utilice await a nivel superior. Dado que require() debe permanecer síncrono, un archivo ESM que pausa su propia evaluación en un await a nivel superior no puede cargarse de esta manera. Si el módulo al que se le aplica require() contiene await a nivel superior, o el grafo de módulos que importa contiene await a nivel superior, se lanzará ERR_REQUIRE_ASYNC_MODULE, y los usuarios deberán cargar el módulo asíncrono usando import() en su lugar. El mensaje lanzado es explícito: “require() cannot be used on an ESM graph with top-level await. Use import() instead.”

La palabra crítica es grafo. El límite no se refiere al archivo que ejecutas con require —se refiere a todo lo que ese archivo importa de forma transitiva.

Un incidente real y documentado muestra el alcance del impacto. En abril de 2026, lru-cache@11.3.0 introdujo un await a nivel superior en su compilación ESM, lo que rompió cualquier módulo CJS que cargara transitivamente la compilación ESM de lru-cache, especialmente jsdom a través de @asamuzakjp/css-color (que es ESM puro sin punto de entrada CJS). La cadena era: jsdom (CJS) → un paquete de colores ESM puro → la entrada ESM de lru-cache, ahora asíncrona. El mapa de exports enrutaba correctamente require a CJS e import a ESM; pero cuando el paquete CJS requería un paquete ESM puro, Node resolvía el grafo ESM, y dentro de ese grafo el punto de entrada ESM de lru-cache —ahora con TLA— hacía imposible cargar todo el grafo con require() de forma síncrona. El mantenedor revirtió el await a nivel superior en un parche posterior, por lo que el problema está resuelto —pero demuestra que este modo de fallo impacta en producción. La misma cascada de ERR_REQUIRE_ASYNC_MODULE afectó a Prettier y a firebase-tools cuando Node 22.12.0 activó la funcionalidad.

require(esm) replantea todo el problema: elimina la razón de interoperabilidad para las compilaciones duales, pero convierte la ausencia de TLA en un contrato. Para un autor que publica solo ESM, el primer await a nivel superior que añadas en cualquier parte de tu grafo accesible mediante require es un cambio disruptivo para todos los consumidores CommonJS. Como argumenta Evert Pot en su blog, si es el primer await podrías romper inadvertidamente a los usuarios de Node.js que usaban require() para importar tu módulo —lo que significa que el primer await a nivel superior en tu proyecto o en cualquiera de tus dependencias podría constituir una nueva versión major si sigues semver. Trátalo como un cambio semver-major.

El await a nivel superior es genuinamente poco común en el código de bibliotecas. Cuando Cheung probó por primera vez la implementación, ninguno de los ~30 paquetes ESM-only de alto impacto probados contenía await a nivel superior —razón por la cual require(esm) síncrono cubre la abrumadora mayoría de los paquetes reales.

¿Aún necesitas una compilación CJS en 2026?

Para la mayoría de los paquetes nuevos, no. Opta por ESM-only por defecto y recurre a una compilación dual solo cuando una restricción específica lo exija. Evalúa tres preguntas:

  1. ¿Cuál es tu versión mínima de Node? Si es Node 22.12+ (y con Node 20 ahora en EOL, debería serlo), todos los consumidores pueden ejecutar require() sobre tu ESM. Distribuye solo ESM. Si genuinamente debes soportar entornos anteriores a 20.19 que aún estén en uso, todavía necesitas una compilación CJS para ellos.
  2. ¿Tu grafo accesible mediante require usa await a nivel superior? Si es así —en tu código o en una dependencia cargada síncronamente— los consumidores CJS encontrarán ERR_REQUIRE_ASYNC_MODULE. O bien elimina el TLA (a menudo un import() diferido en lugar de uno a nivel superior), o mantén una entrada CJS y documenta que los usuarios de require() no están soportados.
  3. ¿Controlas a tus consumidores? Los autores de aplicaciones en una versión actual de Node fijada pueden adoptar ESM-only libremente. Los autores de bibliotecas con consumidores downstream desconocidos aún deben publicar un mapa exports limpio y tratar el TLA como un evento de versionado.

Si ninguna de estas condiciones obliga a un segundo formato, la compilación dual es peso muerto: herramientas adicionales, CI más lento, un artefacto publicado más grande y un riesgo de paquete dual reintroducido sin ningún beneficio.

Migración a ESM-only: la lista de verificación

Migrar a ESM-only es principalmente una simplificación de package.json más una sintaxis de módulo disciplinada. Los pasos:

  1. Establece "type": "module" para que los archivos .js se analicen como ESM.
  2. Simplifica el mapa exports a una única entrada ESM. El mapa dual se convierte en una sola línea:
{
  "type": "module",
  "exports": "./dist/index.js",
  "engines": { "node": ">=22.12.0" }
}

El valor recomendado de engines en la retrospectiva es "^20.19.0 || >=22.12.0"; dado que Node 20 está en EOL, >=22.12.0 solo es perfectamente defendible.

  1. Usa extensiones .js explícitas en las importaciones relativas —ESM las requiere: import { x } from "./util.js", no "./util".
  2. Establece "moduleResolution": "NodeNext" en tsconfig.json para que TypeScript emita y resuelva ESM correctamente, incluyendo las extensiones obligatorias.
  3. Reemplaza las variables globales de CommonJS. ESM no tiene __dirname, __filename ni require. Reconstruyelos desde import.meta:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
  1. Audita el uso de await a nivel superior en tu propio código y en tus dependencias antes de publicar. Si tienes previsto usar TLA más adelante, planifica el incremento de versión major ahora en lugar de distribuirlo como un parche.

Qué implica esto para los autores de bibliotecas

La barrera de interoperabilidad que justificaba las compilaciones duales CJS/ESM ha desaparecido en todas las versiones de Node.js que vale la pena soportar: require(esm) es estable desde la v25.4.0 y está presente en las líneas 22, 24 y 26. La restricción restante es concreta y bien definida —mantén el await a nivel superior fuera de la ruta que atravesará un llamador de require(), y trata el primero como un cambio disruptivo. Para un paquete nuevo que apunte a Node actual, distribuye solo ESM, simplifica el mapa exports y audita tu grafo en busca de TLA antes de publicar.

Preguntas frecuentes

¿Puedo ejecutar require sobre un paquete ESM-only en Node.js 22?

Sí. Node 22 habilitó require(esm) por defecto a partir de v22.12.0, publicado el 3 de diciembre de 2024, por lo que un archivo CommonJS ejecutándose en cualquier versión 22.12 o posterior puede usar require() directamente sobre un paquete ESM-only, siempre que el grafo de ese paquete no contenga await a nivel superior. La funcionalidad fue posteriormente marcada como estable en Node 25.4.0 y retroportada a la línea LTS 24.x en v24.15.0, pero ha sido funcional en Node 22 desde la versión v22.12.0.

¿Cuál es la diferencia entre ERR_REQUIRE_ESM y ERR_REQUIRE_ASYNC_MODULE?

ERR_REQUIRE_ESM era el error antiguo que se lanzaba cuando CommonJS intentaba ejecutar require() sobre cualquier módulo ES, y ya no ocurre en las versiones de Node con soporte activo porque require(esm) gestiona la carga síncrona de ESM. ERR_REQUIRE_ASYNC_MODULE es el error moderno más específico que se lanza únicamente cuando el grafo ESM requerido contiene await a nivel superior, ya que require() no puede esperar una evaluación asíncrona. Su mensaje indica que se debe usar import() en su lugar. El primer error significaba que ESM no estaba soportado; el segundo significa que una funcionalidad específica de ESM no lo está.

¿require(esm) devuelve la exportación por defecto directamente?

No. require(esm) devuelve el objeto de espacio de nombres del módulo completo, no un valor directo, por lo que una exportación por defecto se encuentra en la propiedad .default en lugar de ser el valor de retorno en sí mismo, lo que coincide con el comportamiento de import() dinámico. Esto difiere de un módulo CommonJS tradicional donde require() devuelve module.exports directamente. Si necesitas un único valor de retorno, un módulo ES puede exportarlo usando el nombre de cadena 'module.exports', lo que personaliza lo que devuelve require(esm). Comprueba siempre .default al migrar consumidores fuera de un envoltorio CJS.

¿Cómo compruebo en tiempo de ejecución si require(esm) está disponible?

Comprueba si process.features.require_module es true. Este booleano es establecido por el entorno de ejecución de Node.js y devuelve true en todas las versiones que soportan la carga de módulos ES mediante require, lo que incluye Node 20.19 y posteriores, 22.12 y posteriores, y todas las líneas 24 y 26. Úsalo para bifurcar entre un require() síncrono y un fallback import() asíncrono cuando debas soportar una mezcla de entornos de ejecución más antiguos y más nuevos dentro del mismo código base.

¿Es seguro distribuir solo ESM si mis dependencias usan await a nivel superior?

No para los consumidores CommonJS. El límite de require(esm) se aplica a todo el grafo accesible mediante require, no solo a tus propios archivos, por lo que un await a nivel superior en cualquier parte de una dependencia cargada síncronamente lanzará ERR_REQUIRE_ASYNC_MODULE para cualquiera que use require(). Un incidente documentado de 2026 mostró cómo lru-cache añadió await a nivel superior a su compilación ESM y rompió jsdom de forma transitiva antes de que el mantenedor lo revirtiera. Audita tu grafo de dependencias completo antes de adoptar ESM-only, o mantén una entrada CJS y marca a los usuarios de require() como no soportados.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.