Das Ende dualer CJS/ESM-Builds in Node.js
Node.js unterstützt jetzt require(esm), sodass ESM-only für viele Bibliotheken der Standard ist. Wann CJS fallen kann, top-level await vermeiden und sicher migrieren.
Ab Juni 2026 kann jedes unterstützte Node.js-Release ein ES-Modul per require() laden. Damit entfällt der einzige Grund, aus dem die meisten Bibliotheken überhaupt duale CommonJS/ESM-Builds ausgeliefert haben – für einen großen und wachsenden Teil der Pakete ist ESM-only nun die standardmäßig richtige Wahl. Die Asymmetrie, die ein Jahrzehnt voller Packaging-Probleme geprägt hat – CommonJS konnte nichts aus der ESM-Welt per import oder require laden – gilt auf keiner Runtime mehr, die man noch als Zielplattform in Betracht ziehen sollte. Die duale exports-Map, die parallele Ausgabe von tsup/unbuild, das Jonglieren mit .d.cts/.d.ts-Deklarationen: Der Großteil dieses Apparats existiert, um ein Problem zu lösen, das Node inzwischen im Kern selbst gelöst hat.
Dieser Artikel liefert das Argument für 2026, das ältere Dual-Build-Guides nicht liefern können: die genaue Versions-Timeline, in der die Asymmetrie verschwunden ist, was require(esm) tatsächlich tut und welche eine harte Einschränkung es mitbringt, sowie einen Entscheidungsrahmen dafür, ob man überhaupt noch einen CommonJS-Build benötigt. Die Einschränkung ist nicht verschwunden – sie hat sich verschoben. Der neue Kompatibilitätsvertrag lautet nicht mehr „liefere zwei Formate aus”; er lautet: „Halte deinen synchronen Ladepfad frei von Top-Level-await.”
Wichtigste Erkenntnisse
- Ab Node.js 25.4.0 (veröffentlicht am 19. Januar 2026) ist
require(esm)als stabil markiert, und dieselbe Änderung wurde in die aktiven LTS-Linien zurückportiert – das bedeutet, dass jedes aktuell unterstützte Node.js-Release in der Lage ist, ein ES-Modul perrequire()zu laden. require(esm)wurde zunächst hinter--experimental-require-modulein Node 22 eingeführt, in Node 23 ohne Flag aktiviert, in LTS bei v22.12.0 (3. Dezember 2024) und v20.19.0 zurückportiert und Ende 2025 als stabil deklariert.require(esm)hat genau eine harte Einschränkung: Es kann kein ES-Modul laden, dessen Graph Top-Level-awaitverwendet. In diesem Fall wirdERR_REQUIRE_ASYNC_MODULEgeworfen und der Nutzer angewiesen, stattdessenimport()zu verwenden.- Für einen ESM-only-Autor ist das erste Top-Level-
awaitirgendwo imrequire-erreichbaren Graph eine Breaking Change für jeden CommonJS-Consumer – es sollte als semver-major behandelt werden. - Wenn ein Paket auf Node 22.12+ abzielt und Top-Level-
awaitim Code vermeidet, den CJS-Nutzer perrequire()laden, ist ESM-only nun die standardmäßig richtige Wahl; ein CJS-Build ist nur noch für Runtimes vor 20.19 oder für TLA-Module erforderlich.
Warum duale CJS/ESM-Builds überhaupt existierten
Duale Builds existierten, weil CommonJS kein ES-Modul per require() laden konnte. Die beiden Systeme laden auf unterschiedliche Weise: require() ist synchron und gibt module.exports zurück, sobald der Aufruf abgeschlossen ist, während ESM als bedingungslos asynchron behandelt wurde. Ein synchroner Aufrufer kann nicht auf einen asynchronen Ladevorgang warten, weshalb require('some-esm-package') den Fehler ERR_REQUIRE_ESM warf. Die umgekehrte Richtung funktionierte stets – ESM kann CommonJS per import laden –, was zu der einseitigen Situation führte, mit der Bibliotheksautoren jahrelang leben mussten: ESM für moderne Consumer ausliefern, CommonJS für alle, die noch require() verwenden, und beides über bedingte exports verknüpfen.
Das bedeutete echten Tooling-Overhead. Bundler wie tsup und unbuild erzeugen beide Formate; eine exports-Map in der package.json leitet import zum .mjs-Einstiegspunkt und require zum .cjs-Einstiegspunkt weiter; TypeScript benötigt nebeneinander liegende .d.ts- und .d.cts-Deklarationen, damit beide Auflösungsmodi korrekt typisiert werden. Anthony Fus Dual-Build-Guide von 2021 und Mayanks Walkthrough von 2023 dokumentieren diesen Apparat ausführlich – und beide sind hinsichtlich des Wie nach wie vor korrekt. Sie beantworten nur eine Frage, die für aktuelle Runtimes nicht mehr gestellt werden muss.
Duale Builds bargen außerdem ein strukturelles Risiko: die Dual-Package-Hazard. Wenn ein Abhängigkeitsgraph ein Paket an einer Stelle per import und an einer anderen per require lädt, kann Node zwei separate Kopien laden – den ESM-Build und den CJS-Build – als voneinander unabhängige Modulinstanzen. Jedes Singleton, jeder Cache, jede Registry oder jede instanceof-Prüfung sieht dann zwei divergierende Zustände. Der duale Build, der das Interop-Problem löste, schuf still und leise ein Problem der Zustandsduplizierung.
require(esm): die genauen Versionen, in denen die Asymmetrie verschwand
Discover how at OpenReplay.com.
Die Lösung kam durch die Korrektur einer lange gehegten Annahme. Wie Node-Core-Contributor Joyee Cheung dokumentierte, war ESM selbst gar nicht für bedingungslose Asynchronität konzipiert – vielmehr war es nur für bedingte Asynchronität ausgelegt, nämlich nur dann, wenn der Graph Top-Level-await enthält. Es erschien daher naheliegend, dass require() zumindest ESM-Graphen ohne Top-Level-await unterstützen sollte. Diese Erkenntnis ermöglichte ein synchrones require() von (den meisten) ES-Modulen, und require(esm) wurde darauf aufgebaut.
Der Rollout erfolgte stufenweise über mehrere Release-Linien. Hier ist die Timeline mit Stand Juni 2026:
| Node.js-Linie | require(esm)-Status | Support-Phase (Juni 2026) |
|---|---|---|
| 18.x | Backport nie erhalten | EOL – Umstieg auf 20+ erforderlich |
| 20.x | Ohne Flag ab v20.19.0 | EOL 30. April 2026 |
| 22.x | Standardmäßig aktiv ab v22.12.0 (3. Dez. 2024) | Maintenance LTS |
| 23.x | Ohne Flag (Non-LTS) | EOL |
| 24.x | Stable-Markierung zurückportiert bei v24.15.0 (15. Apr. 2026) | Active LTS |
| 25.x | Als stabil markiert bei v25.4.0 (19. Jan. 2026) | EOL 1. Juni 2026 |
| 26.x | Stabil | Current |
Das Wesentliche: Im v25.4.0-Release entfernte die Änderung „module: mark require(esm) as stable” (PR #60959) den experimentellen Marker, und derselbe Commit wurde in die LTS-Linie bei v24.15.0 zurückportiert. Das Feature war bereits deutlich vor der Stabilisierung standardmäßig ohne Flag aktiv: Node 22.12.0 war das erste LTS-Release, in dem es standardmäßig aktiviert war, und es wurde bei v20.19.0 nach Node 20 zurückportiert. Node 18 erhielt den Backport nie.
Gemäß dem Node.js-Release-Zeitplan sind die unterstützten Linien im Juni 2026 22 (Maintenance LTS), 24 (Active LTS, aktiver Support bis 20. Oktober 2026, danach Sicherheitswartung bis 30. April 2028) und 26 (Current). Alle drei liegen oberhalb der Schwelle, ab der das Flag entfernt wurde. Da Node 18 den Backport nie erhielt und Node 20 am 30. April 2026 das End-of-Life erreicht hat, enthält die niedrigste Version, auf die ein unterstütztes Projekt abzielen sollte, bereits require(esm).
Was require(esm) für Bibliotheksautoren ändert
Ein CommonJS-Consumer auf einer aktuellen Node-Version kann nun ein ESM-only-Paket direkt per require() laden. Die ursprüngliche Begründung für die Auslieferung eines CJS-Builds – dass require()-Aufrufer andernfalls ausgesperrt wären – gilt auf keiner unterstützten Runtime mehr. Wie die Node.js-Dokumentation beschreibt: Wenn das zu ladende ES-Modul die Anforderungen erfüllt, kann require() es laden und das Modul-Namespace-Objekt zurückgeben; in diesem Fall verhält es sich ähnlich wie dynamisches import(), wird aber synchron ausgeführt und gibt das Namespace-Objekt direkt zurück.
Damit entfällt auch die Dual-Package-Hazard. Da ein CommonJS-Aufrufer nun das eigentliche ES-Modul lädt statt einer parallelen CJS-Kopie, gibt es eine Modulinstanz, ein Singleton, einen Cache – das Problem divergierender Zustände, das sorgfältige duale Builds rechtfertigte, entsteht schlicht nicht, wenn es nur einen Build gibt.
Ein Interop-Detail ist relevant, wenn man den CJS-Wrapper weglässt. require(esm) gibt ein Namespace-Objekt zurück, keinen nackten Wert, sodass ein Default-Export auf .default liegt statt der Rückgabewert selbst zu sein – ähnlich den Ergebnissen, die import() zurückgibt. Wenn man einen CommonJS-ähnlichen einzelnen Rückgabewert möchte, kann das ES-Modul den gewünschten Wert unter dem String-Namen "module.exports" exportieren, um anzupassen, was require(esm) direkt zurückgibt.
Die Unterstützung lässt sich zur Laufzeit erkennen, wenn ein Fallback-Pfad benötigt wird, indem geprüft wird, ob process.features.require_module den Wert true hat.
// Laufzeit-Feature-Erkennung — true auf Node 20.19+, 22.12+ und allen 24/26-Versionen.
if (process.features.require_module) {
const lib = require("some-esm-only-package");
// Default-Export liegt auf .default
const fn = lib.default ?? lib;
}
Die eine Einschränkung: Top-Level-await ist der neue Kompatibilitätsvertrag
require(esm) hat genau eine harte Einschränkung: Es kann kein ES-Modul laden, dessen Graph Top-Level-await verwendet. Da require() synchron bleiben muss, kann eine ESM-Datei, die ihre eigene Auswertung durch ein Top-Level-await unterbricht, auf diesem Weg nicht geladen werden. Wenn das per require() geladene Modul Top-Level-await enthält oder der von ihm importierte Modulgraph Top-Level-await enthält, wird ERR_REQUIRE_ASYNC_MODULE geworfen, und Nutzer sollten das asynchrone Modul stattdessen per import() laden. Die geworfene Fehlermeldung ist eindeutig: „require() cannot be used on an ESM graph with top-level await. Use import() instead.”
Das entscheidende Wort ist Graph. Die Einschränkung betrifft nicht die Datei, die man per require() lädt – sie betrifft alles, was diese Datei transitiv importiert.
Ein realer, datierter Vorfall zeigt den Schadensradius. Im April 2026 führte lru-cache@11.3.0 ein Top-Level-await in seinem ESM-Build ein, was jeden CJS-Modul, der den ESM-Build von lru-cache transitiv lud, zum Absturz brachte – am prominentesten jsdom über @asamuzakjp/css-color (das reines ESM ohne CJS-Einstiegspunkt ist). Die Kette verlief: jsdom (CJS) → ein reines ESM-Color-Paket → lru-caches nun asynchroner ESM-Einstiegspunkt. Die exports-Map leitete require korrekt zu CJS und import zu ESM weiter; aber als das CJS-Paket ein reines ESM-Paket per require lud, löste Node den ESM-Graph auf, und innerhalb dieses Graphen machte lru-caches ESM-Einstiegspunkt – der nun TLA enthielt – den gesamten Graph unmöglich synchron per require() ladbar. Der Maintainer revertierte das Top-Level-await in einem nachfolgenden Patch, sodass der Fehler behoben ist – aber er beweist, dass dieser Fehlermodus im Produktivbetrieb zuschlägt. Dieselbe ERR_REQUIRE_ASYNC_MODULE-Kaskade traf Prettier und firebase-tools, als Node 22.12.0 das Feature aktivierte.
require(esm) rahmt das gesamte Problem neu: Es beseitigt den Interop-Grund für duale Builds, macht aber TLA-Freiheit zu einem Vertrag. Für einen ESM-only-Autor ist das erste Top-Level-await, das irgendwo im require-erreichbaren Graph hinzugefügt wird, eine Breaking Change für jeden CommonJS-Consumer. Wie Evert Pot argumentiert: Wenn es das erste await ist, könnte man versehentlich Node.js-Nutzer brechen, die require() verwendet haben, um das Modul einzubinden – was bedeutet, dass das erste Top-Level-await im Projekt oder in einer seiner Abhängigkeiten bei Einhaltung von Semver eine neue Major-Version begründen könnte. Es sollte als semver-major behandelt werden.
Top-Level-await ist in Bibliothekscode tatsächlich unüblich. Als Cheung die Implementierung erstmals testete, enthielt keines der ~30 getesteten hochrelevanten ESM-only-Pakete Top-Level-await – weshalb synchrones require(esm) die überwältigende Mehrheit realer Pakete abdeckt.
Braucht man 2026 noch einen CJS-Build?
Für die meisten neuen Pakete: nein. Der Standard sollte ESM-only sein; ein dualer Build sollte nur dann in Betracht gezogen werden, wenn eine spezifische Einschränkung ihn erzwingt. Die Entscheidung hängt von drei Fragen ab:
- Was ist das minimale Node-Ziel? Wenn es Node 22.12+ ist (und mit Node 20 nun EOL sollte es das sein), kann jeder Consumer das ESM per
require()laden. ESM-only ausliefern. Wenn wirklich noch Runtimes vor 20.19 unterstützt werden müssen, die noch im Einsatz sind, wird für diese weiterhin ein CJS-Build benötigt. - Verwendet der
require-erreichbare Graph Top-Level-await? Wenn ja – im eigenen Code oder einer synchron geladenen Abhängigkeit – werden CJS-Consumer aufERR_REQUIRE_ASYNC_MODULEstoßen. Entweder das TLA entfernen (oft durch ein lazyimport()statt eines Top-Level-import) oder einen CJS-Einstiegspunkt behalten und dokumentieren, dassrequire()-Nutzer nicht unterstützt werden. - Werden die eigenen Consumer kontrolliert? Anwendungsautoren auf einem gepinnten aktuellen Node können problemlos auf ESM-only umsteigen. Bibliotheksautoren mit unbekannten nachgelagerten Consumern sollten weiterhin eine saubere
exports-Map veröffentlichen und TLA als Versionierungsereignis behandeln.
Wenn keiner dieser Punkte ein zweites Format erzwingt, ist der duale Build toter Ballast: zusätzliches Tooling, langsamere CI, ein größeres veröffentlichtes Artefakt und eine wiedereingeführte Dual-Package-Hazard – ohne jeden Nutzen.
Migration zu ESM-only: die Checkliste
Der Wechsel zu ESM-only ist im Wesentlichen eine Vereinfachung der package.json plus disziplinierte Modulsyntax. Die Schritte:
"type": "module"setzen, damit.js-Dateien als ESM geparst werden.- Die
exports-Map auf einen einzigen ESM-Einstiegspunkt reduzieren. Die duale Map wird zu einer Zeile:
{
"type": "module",
"exports": "./dist/index.js",
"engines": { "node": ">=22.12.0" }
}
Der empfohlene engines-Wert lautet "^20.19.0 || >=22.12.0"; da Node 20 EOL ist, ist >=22.12.0 allein vertretbar.
- Explizite
.js-Erweiterungen in relativen Importen verwenden – ESM erfordert sie:import { x } from "./util.js", nicht"./util". "moduleResolution": "NodeNext"intsconfig.jsonsetzen, damit TypeScript ESM korrekt ausgibt und auflöst, einschließlich der obligatorischen Erweiterungen.- CommonJS-Globals ersetzen. ESM kennt kein
__dirname,__filenameoderrequire. Diese lassen sich ausimport.metarekonstruieren:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
- Vor der Veröffentlichung auf Top-Level-
awaitim eigenen Code und in Abhängigkeiten prüfen. Wenn TLA später eingesetzt werden soll, sollte der Major-Version-Bump jetzt geplant werden, statt ihn als Patch auszuliefern.
Was das für Bibliotheksautoren bedeutet
Die Interop-Mauer, die duale CJS/ESM-Builds rechtfertigte, ist auf jeder Node.js-Version gefallen, die es wert ist, unterstützt zu werden: require(esm) ist ab v25.4.0 stabil und in den Linien 22, 24 und 26 vorhanden. Die verbleibende Einschränkung ist eng und benennbar – Top-Level-await aus dem Pfad heraushalten, den ein require()-Aufrufer durchläuft, und das erste solche await als Breaking Change behandeln. Für ein neues Paket, das auf aktuelles Node abzielt: ESM-only ausliefern, die exports-Map vereinfachen und den Graph vor der Veröffentlichung auf TLA prüfen.
FAQs
Kann ich ein ESM-only-Paket auf Node.js 22 per require() laden?
Ja. Node 22 hat require(esm) ab v22.12.0 (veröffentlicht am 3. Dezember 2024) standardmäßig aktiviert, sodass eine CommonJS-Datei, die auf einer Version ab 22.12 läuft, ein ESM-only-Paket direkt per require() laden kann, vorausgesetzt, der Graph dieses Pakets enthält kein Top-Level-await. Das Feature wurde später in Node 25.4.0 als stabil markiert und in die 24.x-LTS-Linie bei v24.15.0 zurückportiert, ist aber auf Node 22 seit dem v22.12.0-Release funktionsfähig.
Was ist der Unterschied zwischen ERR_REQUIRE_ESM und ERR_REQUIRE_ASYNC_MODULE?
ERR_REQUIRE_ESM war der alte Fehler, der geworfen wurde, wenn CommonJS versuchte, ein ES-Modul per require() zu laden; er tritt auf unterstützten Node-Versionen nicht mehr auf, da require(esm) das synchrone Laden von ESM übernimmt. ERR_REQUIRE_ASYNC_MODULE ist der engere moderne Fehler, der nur geworfen wird, wenn der per require() geladene ESM-Graph Top-Level-await enthält, da require() nicht auf eine asynchrone Auswertung warten kann. Die Fehlermeldung weist an, stattdessen import() zu verwenden. Der erste Fehler bedeutete, dass ESM nicht unterstützt wurde; der zweite bedeutet, dass ein spezifisches ESM-Feature nicht unterstützt wird.
Gibt require(esm) den Default-Export direkt zurück?
Nein. require(esm) gibt das vollständige Modul-Namespace-Objekt zurück, keinen nackten Wert, sodass ein Default-Export auf der .default-Eigenschaft liegt statt der Rückgabewert selbst zu sein – entsprechend dem Verhalten von dynamischem import(). Das unterscheidet sich von einem traditionellen CommonJS-Modul, bei dem require() module.exports direkt zurückgibt. Wenn ein einzelner Rückgabewert benötigt wird, kann ein ES-Modul ihn unter dem String-Namen 'module.exports' exportieren, was anpasst, was require(esm) zurückgibt. Beim Migrieren von Consumern weg von einem CJS-Wrapper sollte stets auf .default geprüft werden.
Wie prüfe ich zur Laufzeit, ob require(esm) verfügbar ist?
Es wird geprüft, ob process.features.require_module den Wert true hat. Dieser Boolean wird von der Node.js-Runtime gesetzt und gibt true auf jeder Version zurück, die das Laden von ES-Modulen per require() unterstützt – das schließt Node 20.19 und später, 22.12 und später sowie alle 24- und 26-Linien ein. Er kann verwendet werden, um zwischen einem synchronen require() und einem asynchronen import()-Fallback zu verzweigen, wenn eine Mischung aus älteren und neueren Runtimes innerhalb derselben Codebasis unterstützt werden muss.
Ist ESM-only sicher, wenn Abhängigkeiten Top-Level-await verwenden?
Nicht für CommonJS-Consumer. Die require(esm)-Einschränkung gilt für den gesamten require-erreichbaren Graph, nicht nur für die eigenen Dateien, sodass ein Top-Level-await irgendwo in einer synchron geladenen Abhängigkeit ERR_REQUIRE_ASYNC_MODULE für alle auslöst, die require() verwenden. Ein dokumentierter Vorfall aus 2026 zeigte, wie lru-cache Top-Level-await zu seinem ESM-Build hinzufügte und jsdom transitiv brach, bevor der Maintainer es revertierte. Der vollständige Abhängigkeitsgraph sollte vor dem Wechsel zu ESM-only geprüft werden, oder ein CJS-Einstiegspunkt wird beibehalten und require()-Nutzer als nicht unterstützt markiert.
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