12k
All articles

O Fim das Builds Duplas CJS/ESM no Node.js

Node.js agora suporta require(esm), tornando ESM-only a opção padrão para muitas bibliotecas. Quando abandonar CJS, evitar top-level await e migrar com segurança.

OpenReplay Team
OpenReplay Team
O Fim das Builds Duplas CJS/ESM no Node.js

A partir de junho de 2026, todas as versões suportadas do Node.js podem executar require() em um módulo ES, o que elimina o único motivo pelo qual a maioria das bibliotecas já distribuiu builds duplas CommonJS/ESM — para uma parcela grande e crescente de pacotes, ESM-only é agora a escolha padrão correta. A assimetria que definiu uma década de dores com empacotamento — CommonJS não conseguia import nada, nem require nada do mundo ESM — não se aplica mais em nenhum runtime que você ainda deva ter como alvo. O mapa duplo de exports, a saída paralela do tsup/unbuild, a manipulação de declarações .d.cts/.d.ts: a maior parte dessa maquinaria existe para resolver um problema que o Node já resolveu no núcleo.

Este artigo apresenta o argumento de 2026 que os guias mais antigos de build dupla não conseguem fazer: aqui está o cronograma exato de versões em que a assimetria morreu, o que require(esm) realmente faz e o único limite rígido que ele carrega, além de um framework de decisão para determinar se você ainda precisa de uma build CommonJS. A restrição não desapareceu — ela se deslocou. O novo contrato de compatibilidade não é “distribua dois formatos”; é “mantenha seu caminho de carregamento síncrono livre de top-level await.”

Principais Conclusões

  • A partir do Node.js 25.4.0 (lançado em 19 de janeiro de 2026), require(esm) é marcado como estável, e a mesma mudança foi retroportada para as linhas LTS ativas — o que significa que todas as versões do Node.js atualmente suportadas incluem a capacidade de executar require() em um módulo ES.
  • require(esm) chegou pela primeira vez por trás de --experimental-require-module no Node 22, foi desativado como experimental no Node 23, retroportado para LTS em v22.12.0 (3 de dezembro de 2024) e v20.19.0, e declarado estável no final de 2025.
  • require(esm) tem exatamente um limite rígido: ele não consegue carregar um módulo ES cujo grafo utiliza top-level await, o que lança ERR_REQUIRE_ASYNC_MODULE e instrui o uso de import() no lugar.
  • Para um autor de pacotes ESM-only, o primeiro await de nível superior em qualquer lugar do seu grafo acessível via require é uma mudança incompatível para todo consumidor CommonJS — trate-o como um semver-major.
  • Se o seu pacote tem como alvo o Node 22.12+ e evita top-level await no código que usuários CJS irão require(), distribuir apenas ESM é agora a escolha padrão correta; mantenha uma build CJS apenas para runtimes anteriores ao 20.19 ou módulos com TLA.

Por que builds duplas CJS/ESM existiam

Builds duplas existiam porque o CommonJS não conseguia executar require() em um módulo ES. Os dois sistemas carregam de forma diferente: require() é síncrono e retorna module.exports no momento em que a chamada é concluída, enquanto o ESM era tratado como incondicionalmente assíncrono. Um chamador síncrono não pode aguardar um carregamento assíncrono, então require('some-esm-package') lançava ERR_REQUIRE_ESM. A direção inversa sempre funcionou — ESM pode import CommonJS — o que produziu a situação assimétrica com a qual os autores de bibliotecas conviveram por anos: distribua ESM para consumidores modernos, distribua CommonJS para todos que ainda chamam require(), e conecte ambos por meio de exports condicionais.

Isso significava uma sobrecarga real de ferramentas. Bundlers como tsup e unbuild emitem ambos os formatos; um mapa de exports no package.json roteia import para a entrada .mjs e require para a .cjs; o TypeScript precisa de declarações .d.ts e .d.cts colocalizadas para que ambos os modos de resolução funcionem com verificação de tipos. O guia de build dupla de 2021 de Anthony Fu e o guia detalhado de 2023 de Mayank documentam essa maquinaria em profundidade — e ambos ainda são precisos sobre como fazer. Eles simplesmente respondem a uma pergunta que, para os runtimes atuais, não precisa mais ser feita.

Builds duplas também carregavam um risco estrutural: o dual-package hazard. Quando um grafo de dependências carrega seu pacote via import em um lugar e require em outro, o Node pode carregar duas cópias separadas — a build ESM e a build CJS — como instâncias de módulo distintas. Qualquer singleton, cache, registro ou verificação de instanceof então vê dois estados divergentes. A build dupla que resolveu o problema de interoperabilidade criou silenciosamente um problema de duplicação de estado.

require(esm): as versões exatas em que a assimetria morreu

A correção veio de uma revisão de uma premissa há muito aceita. Como o contribuidor do núcleo do Node, Joyee Cheung, documentou, o ESM em si não foi projetado para ser incondicionalmente assíncrono — pelo contrário, foi projetado para ser apenas condicionalmente assíncrono, somente quando o grafo contém top-level await, então pareceria natural que require() ao menos suportasse grafos ESM que não contêm top-level await. Essa percepção tornou possível um require() síncrono da maioria dos módulos ES, e require(esm) foi construído sobre ela.

A implantação aconteceu em etapas nas linhas de lançamento. Aqui está o cronograma em junho de 2026:

Linha do Node.jsStatus do require(esm)Fase de suporte (junho de 2026)
18.xNunca recebeu a retroportaçãoEOL — deve migrar para 20+
20.xDesativado como experimental em v20.19.0EOL em 30 de abril de 2026
22.xAtivado por padrão em v22.12.0 (3 de dez. de 2024)Maintenance LTS
23.xDesativado como experimental (não-LTS)EOL
24.xMarcação de estabilidade retroportada em v24.15.0 (15 de abr. de 2026)Active LTS
25.xMarcado como estável em v25.4.0 (19 de jan. de 2026)EOL em 1 de junho de 2026
26.xEstávelCurrent

O destaque: no lançamento v25.4.0, a mudança “module: mark require(esm) as stable” (PR #60959) removeu a marcação experimental, e esse mesmo commit foi retroportado para a linha LTS em v24.15.0. O recurso havia sido desativado como experimental por padrão bem antes da estabilização: Node 22.12.0 foi o primeiro lançamento LTS com ele ativado por padrão, e foi retroportado para o Node 20 em v20.19.0. O Node 18 nunca recebeu a retroportação.

De acordo com o calendário de lançamentos do Node.js, as linhas suportadas em junho de 2026 são 22 (Maintenance LTS), 24 (Active LTS, suporte ativo até 20 de outubro de 2026, depois manutenção de segurança até 30 de abril de 2028) e 26 (Current). As três estão acima do limite de desativação experimental. Com o Node 18 nunca tendo recebido a retroportação e o Node 20 tendo atingido o fim de vida em 30 de abril de 2026, a versão mais baixa que qualquer projeto suportado deve ter como alvo já inclui require(esm).

O que require(esm) muda para autores de bibliotecas

Um consumidor CommonJS em uma versão atual do Node agora pode executar require() diretamente em um pacote ESM-only. A justificativa original para distribuir uma build CJS — que os chamadores de require() ficariam bloqueados de outra forma — não se aplica mais em nenhum runtime suportado. Como a documentação do Node.js descreve, se o módulo ES sendo carregado atende aos requisitos, require() pode carregá-lo e retornar o objeto de namespace do módulo; neste caso, é similar ao import() dinâmico, mas é executado de forma síncrona e retorna o objeto de namespace diretamente.

Isso também aposenta o dual-package hazard. Como um chamador CommonJS agora carrega o módulo ES real em vez de uma cópia CJS paralela, há uma instância de módulo, um singleton, um cache — o problema de estado divergente que justificava builds duplas cuidadosas simplesmente não surge quando há apenas uma build.

Um detalhe de interoperabilidade importa quando você remove o wrapper CJS. require(esm) retorna um objeto de namespace, não um valor simples, então uma exportação padrão fica em .default em vez de ser o próprio valor de retorno, similar aos resultados retornados por import(). Se você quiser um único valor de retorno no estilo CommonJS, o módulo ES pode exportar o valor desejado usando o nome de string "module.exports" para personalizar o que require(esm) retorna diretamente.

Você pode detectar o suporte em tempo de execução quando precisar de um caminho de fallback verificando se process.features.require_module é true.

// Detecção de recurso em tempo de execução — true no Node 20.19+, 22.12+, e todo o 24/26.
if (process.features.require_module) {
  const lib = require("some-esm-only-package");
  // exportação padrão está em .default
  const fn = lib.default ?? lib;
}

O único limite: top-level await é o novo contrato de compatibilidade

require(esm) tem exatamente um limite rígido: ele não consegue carregar um módulo ES cujo grafo utiliza top-level await. Como require() deve permanecer síncrono, um arquivo ESM que pausa sua própria avaliação em um await de nível superior não pode ser carregado dessa forma. Se o módulo sendo require()’d contém top-level await, ou o grafo de módulos que ele importa contém top-level await, ERR_REQUIRE_ASYNC_MODULE será lançado, e os usuários devem carregar o módulo assíncrono usando import() no lugar. A mensagem lançada é explícita: “require() cannot be used on an ESM graph with top-level await. Use import() instead.”

A palavra crítica é grafo. O limite não é sobre o arquivo que você executa require — é sobre tudo que esse arquivo importa transitivamente.

Um incidente real e datado mostra o raio de impacto. Em abril de 2026, lru-cache@11.3.0 introduziu um top-level await em sua build ESM, o que quebrou qualquer módulo CJS que carregasse transitivamente a build ESM do lru-cache, mais notavelmente o jsdom via @asamuzakjp/css-color (que é ESM puro sem entrypoint CJS). A cadeia era: jsdom (CJS) → um pacote de cores ESM puro → a entrada ESM do lru-cache, agora assíncrona. O mapa de exports roteava corretamente require para CJS e import para ESM; mas quando o pacote CJS executava require em um pacote ESM puro, o Node resolvia o grafo ESM, e dentro desse grafo o entrypoint ESM do lru-cache — agora contendo TLA — tornava todo o grafo impossível de ser carregado com require() de forma síncrona. O mantenedor reverteu o top-level await em um patch subsequente, então a quebra está resolvida — mas isso prova que o modo de falha ocorre em produção. A mesma cascata de ERR_REQUIRE_ASYNC_MODULE atingiu o Prettier e o firebase-tools quando o Node 22.12.0 ativou o recurso.

require(esm) reformula todo o problema: ele remove o motivo de interoperabilidade para builds duplas, mas torna a ausência de TLA um contrato. Para um autor de pacotes ESM-only, o primeiro await de nível superior que você adicionar em qualquer lugar do seu grafo acessível via require é uma mudança incompatível para todo consumidor CommonJS. Como Evert Pot argumenta, se for o primeiro await, você pode inadvertidamente quebrar usuários do Node.js que usavam require() para importar seu módulo — o que significa que o primeiro top-level await no seu projeto ou em qualquer uma de suas dependências pode agora constituir uma nova versão major se você seguir semver. Trate-o como um semver-major.

Top-level await é genuinamente incomum em código de biblioteca. Quando Cheung testou pela primeira vez a implementação, nenhum dos ~30 pacotes ESM-only de alto impacto testados continha top-level await — o que é por isso que require(esm) síncrono cobre a esmagadora maioria dos pacotes reais.

Você ainda precisa de uma build CJS em 2026?

Para a maioria dos novos pacotes, não. Adote ESM-only como padrão e recorra a uma build dupla apenas quando uma restrição específica forçar isso. Ramifique em três perguntas:

  1. Qual é o seu alvo mínimo do Node? Se for Node 22.12+ (e com o Node 20 agora em EOL, deveria ser), todo consumidor pode executar require() no seu ESM. Distribua ESM-only. Se você genuinamente precisa suportar runtimes anteriores ao 20.19 ainda em uso, você ainda precisa de uma build CJS para eles.
  2. O seu grafo acessível via require usa top-level await? Se sim — no seu código ou em uma dependência carregada de forma síncrona — os consumidores CJS irão encontrar ERR_REQUIRE_ASYNC_MODULE. Ou remova o TLA (frequentemente um import() lazy em vez de um de nível superior), ou mantenha uma entrada CJS e documente que usuários de require() não são suportados.
  3. Você controla seus consumidores? Autores de aplicações em um Node atual fixado podem adotar ESM-only livremente. Autores de bibliotecas com consumidores downstream desconhecidos ainda devem publicar um mapa de exports limpo e tratar TLA como um evento de versionamento.

Se nenhuma dessas condições forçar um segundo formato, a build dupla é peso morto: ferramentas extras, CI mais lento, um artefato publicado maior e um dual-package hazard reintroduzido sem nenhum benefício.

Migrando para ESM-only: o checklist

Migrar para ESM-only é principalmente uma simplificação do package.json mais sintaxe de módulo disciplinada. Os passos:

  1. Defina "type": "module" para que arquivos .js sejam interpretados como ESM.
  2. Simplifique o mapa de exports para uma única entrada ESM. O mapa duplo se torna uma linha:
{
  "type": "module",
  "exports": "./dist/index.js",
  "engines": { "node": ">=22.12.0" }
}

O valor de engines recomendado é "^20.19.0 || >=22.12.0"; como o Node 20 está em EOL, >=22.12.0 sozinho é defensável.

  1. Use extensões .js explícitas em importações relativas — o ESM as exige: import { x } from "./util.js", não "./util".
  2. Defina "moduleResolution": "NodeNext" no tsconfig.json para que o TypeScript emita e resolva ESM corretamente, incluindo as extensões obrigatórias.
  3. Substitua os globais do CommonJS. O ESM não tem __dirname, __filename ou require. Reconstrua-os a partir de import.meta:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
  1. Audite o top-level await em todo o seu código e dependências antes de publicar. Se você pretende usar TLA mais tarde, planeje o incremento de versão major agora, em vez de distribuí-lo como um patch.

Onde isso deixa os autores de bibliotecas

A barreira de interoperabilidade que justificava builds duplas CJS/ESM desapareceu em todas as versões do Node.js que valem a pena suportar: require(esm) é estável a partir da v25.4.0 e está presente nas linhas 22, 24 e 26. A restrição restante é estreita e nomeável — mantenha o top-level await fora do caminho que um chamador de require() irá percorrer, e trate o primeiro como uma mudança incompatível. Para um novo pacote tendo como alvo o Node atual, distribua ESM-only, simplifique o mapa de exports e audite seu grafo em busca de TLA antes de publicar.

Perguntas Frequentes

Posso executar require em um pacote ESM-only no Node.js 22?

Sim. O Node 22 habilitou require(esm) por padrão a partir da v22.12.0, lançada em 3 de dezembro de 2024, então um arquivo CommonJS executando em qualquer versão 22.12 ou posterior pode executar require() em um pacote ESM-only diretamente, desde que o grafo desse pacote não contenha top-level await. O recurso foi posteriormente marcado como estável no Node 25.4.0 e retroportado para a linha LTS 24.x em v24.15.0, mas já era funcional no Node 22 desde o lançamento v22.12.0.

Qual é a diferença entre ERR_REQUIRE_ESM e ERR_REQUIRE_ASYNC_MODULE?

ERR_REQUIRE_ESM era o antigo erro lançado sempre que o CommonJS tentava executar require() em qualquer módulo ES, e não ocorre mais em versões suportadas do Node porque require(esm) lida com o carregamento síncrono de ESM. ERR_REQUIRE_ASYNC_MODULE é o erro moderno mais específico lançado somente quando o grafo ESM requerido contém top-level await, já que require() não pode aguardar uma avaliação assíncrona. Sua mensagem instrui o uso de import() no lugar. O primeiro erro significava que ESM não era suportado; o segundo significa que um recurso específico do ESM não é.

require(esm) retorna a exportação padrão diretamente?

Não. require(esm) retorna o objeto de namespace completo do módulo, não um valor simples, então uma exportação padrão fica na propriedade .default em vez de ser o próprio valor de retorno, correspondendo ao comportamento do import() dinâmico. Isso difere de um módulo CommonJS tradicional onde require() retorna module.exports diretamente. Se você precisar de um único valor de retorno, um módulo ES pode exportá-lo usando o nome de string 'module.exports', o que personaliza o que require(esm) retorna. Sempre verifique .default ao migrar consumidores de um wrapper CJS.

Como verifico em tempo de execução se require(esm) está disponível?

Verifique se process.features.require_module é true. Esse booleano é definido pelo runtime do Node.js e retorna true em todas as versões que suportam a execução de require em módulos ES, o que inclui Node 20.19 e posterior, 22.12 e posterior, e todas as linhas 24 e 26. Use-o para ramificar entre um require() síncrono e um fallback assíncrono com import() quando você precisar suportar uma mistura de runtimes mais antigos e mais novos dentro do mesmo código.

É seguro distribuir ESM-only se minhas dependências usam top-level await?

Não para consumidores CommonJS. O limite do require(esm) se aplica a todo o grafo acessível via require, não apenas aos seus próprios arquivos, então um top-level await em qualquer lugar de uma dependência carregada de forma síncrona lançará ERR_REQUIRE_ASYNC_MODULE para qualquer pessoa usando require(). Um incidente documentado em 2026 viu o lru-cache adicionar top-level await à sua build ESM e quebrar o jsdom transitivamente antes de o mantenedor revertê-lo. Audite todo o seu grafo de dependências antes de adotar ESM-only, ou mantenha uma entrada CJS e marque os usuários de require() como não suportados.

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.