JS
() =>
JavaScript · ES Module · async/await · Node.js
Top-Level Await in JavaScript
await direkt im Modul-Scope — ohne async-Wrapper

Top-Level Await war einer der meistgewünschten Features in JavaScript-Modulen: Direkt im Modul-Scope auf asynchrone Initialisierung warten, ohne das gesamte Modul in eine async-Funktion zu hüllen. Die Auswirkungen auf den Modul-Graphen und mögliche Deadlocks verstehen die wenigsten — dieser Guide erklärt beides.

10 Min. Lesezeit ES2022 · Node.js 14.8+ · Vite · Webpack 5 Top-Level Await · Dynamic Import · Modul-Graph

1. Das Problem vor Top-Level Await

Vor der Einführung von Top-Level Await gab es in JavaScript-Modulen keine Möglichkeit, direkt im Modul-Scope auf asynchrone Operationen zu warten. Das klassische Problem: Ein Modul möchte beim Laden eine Datenbankverbindung herstellen, eine Konfigurationsdatei einlesen oder einen API-Schlüssel aus einem Secret Manager abrufen. All das ist asynchron, aber ein ES-Modul hat keinen async-Kontext und kann kein await auf der obersten Ebene verwenden — zumindest vor ES2022.

Als Workaround etablierten sich drei fragile Muster: Erstens die async IIFE (Immediately Invoked Function Expression) — (async () => { await init(); })(); — die den Modul-Scope scheinbar umgeht, aber keine Exports wartet lassen kann. Zweitens Callback-basierte Initialisierung, die Exports abhängig von einem Initialisierungsstatus macht und jeden Verwender zwingt, diesen Status zu prüfen. Drittens das Exportieren von Promises statt der Werte selbst, was alle Verwender zwingt, ihrerseits await zu nutzen. Top-Level Await macht alle drei Workarounds überflüssig.


// BEFORE top-level await: three fragile workarounds

// 1. async IIFE — exports are not ready when module is imported
let db;
(async () => {
  db = await connectDatabase(); // consumers might use db before it's set!
})();
export { db }; // undefined at import time — race condition

// 2. Export a promise — forces every consumer to await
export const dbPromise = connectDatabase();
// Consumer: const db = await dbPromise; — leaks into every module

// 3. Lazy init with flag — complex and error-prone
let _db = null;
export async function getDb() {
  if (!_db) _db = await connectDatabase();
  return _db;
}
// Consumer: const db = await getDb(); — repeated boilerplate everywhere

// AFTER top-level await (ES2022): clean and direct
const db = await connectDatabase(); // module waits here
export { db }; // guaranteed to be a real connection, not a promise

2. Wie Top-Level Await den Modul-Graphen beeinflusst

Top-Level Await ist kein einfaches syntaktisches Feature — es verändert die Ladereihenfolge des gesamten Modul-Graphen. Wenn ein Modul A auf Modul B zeigt und B ein Top-Level Await enthält, dann wartet A darauf, dass B vollständig geladen und initialisiert ist, bevor A weiterläuft. Das bedeutet: Module, die von einem Modul mit Top-Level Await abhängen, werden verzögert — und diese Verzögerung pflanzt sich durch den gesamten Abhängigkeitsbaum fort.

Die JavaScript-Engine behandelt ein Modul mit Top-Level Await intern wie eine async-Funktion: Die Ausführung pausiert an jeder await-Stelle, gibt die Kontrolle zurück und resumiert, sobald die Promise erfüllt ist. Geschwister-Module im Abhängigkeitsgraph können in der Zwischenzeit parallel geladen werden — aber alle Module, die direkt oder transitiv von dem wartenden Modul abhängen, müssen warten. Das macht es wichtig zu verstehen, dass Top-Level Await nicht nur das eigene Modul betrifft, sondern alles, was davon importiert.

3. Legitime Anwendungsfälle: Konfiguration, DB, Feature-Detection

Der sinnvollste Einsatz von Top-Level Await ist die Modul-Initialisierung, die von externer asynchroner Arbeit abhängt. Drei Hauptanwendungsfälle: Erstens das Laden von Konfiguration aus einer externen Quelle — Environment-Variablen aus einem Secrets Manager, Feature-Flags aus einem Remote-Config-Service oder dynamisch geladene Locale-Dateien. Mit Top-Level Await enthält das Konfigurationsmodul beim Import garantiert valide Werte, nicht Promises.

Zweitens Feature-Detection für Browser-APIs: const supportsWebGPU = await navigator.gpu?.requestAdapter() !== null — hier muss auf die asynchrone Antwort des Browsers gewartet werden, bevor das Modul entscheiden kann, welche Implementierung es exportiert. Drittens konditionaler Dynamic Import — das Modul lädt je nach erkannter Plattform oder Fähigkeit unterschiedliche Implementierungen nach. All das war vor Top-Level Await nur mit Workarounds möglich, die entweder die Typsicherheit oder die Exportzuverlässigkeit beeinträchtigten.


// config.js — load secrets at module init time
const response = await fetch("https://config.internal/api/settings");
const config = await response.json();

export const { apiKey, dbUrl, featureFlags } = config;
// Any module importing from config.js gets real values, not promises

// feature-detection.js — async browser capability check
const gpu = await navigator.gpu?.requestAdapter();
export const hasWebGPU = gpu !== null;

// Conditional implementation loading based on detected features
export const imageProcessor = hasWebGPU
  ? await import("./gpu-processor.js").then(m => m.default)
  : await import("./cpu-processor.js").then(m => m.default);

// database.js — established connection before any export is available
import { createPool } from "pg";

const pool = createPool({ connectionString: process.env.DATABASE_URL });
await pool.query("SELECT 1"); // verify connection is alive

export { pool };
// Importing modules can use pool directly — it's always connected

4. Top-Level Await mit Dynamic Import kombinieren

Die Kombination von Top-Level Await und Dynamic Import ist besonders mächtig für Code-Splitting-Szenarien, die von asynchronen Entscheidungen abhängen. Dynamic Import (import()) gibt immer eine Promise zurück — vor Top-Level Await musste diese Promise in einer async-Funktion aufgelöst werden. Direkt im Modul-Scope konnte man nicht warten. Mit Top-Level Await ist const module = await import("./heavy-module.js") direkt auf der obersten Ebene möglich.

Besonders interessant ist das Muster der bedingten Implementierungsauswahl: Ein Modul entscheidet zur Ladezeit, welche Implementierung es bereitstellt, und lädt die richtige Version nach. Das ist der sauberere Ersatz für Polyfill-Ladepatternen, die bisher entweder synchron (und damit blockierend) oder via IIFE (und damit unsicher bezüglich Timing) implementiert wurden. Top-Level Await macht dieses Muster deklarativ und typsicher — das exportierte Interface ist immer die geladene Implementierung, nicht ein Promise darauf.

5. Performance-Fallstricke: sequenzielle vs. parallele Awaits

Der gravierendste Fehler beim Einsatz von Top-Level Await ist sequenzielles Awaiting unabhängiger Operationen. Schreibt man zwei await-Ausdrücke untereinander, werden sie nacheinander ausgeführt — auch wenn sie vollständig unabhängig voneinander sind. Wenn das Laden von Konfiguration 200ms dauert und das Datenbankverbinden 300ms, dann dauert das Modul-Laden 500ms statt der möglichen 300ms. Das ist ein direkter Ladezeitverlust, der im kritischen Pfad des Anwendungsstarts besonders schmerzt.

Die Lösung ist Promise.all() für unabhängige Operationen: const [config, db] = await Promise.all([loadConfig(), connectDb()]). Das ist dieselbe Technik wie in async-Funktionen, aber in Top-Level-Position besonders wichtig, weil das Modul den gesamten nachgelagerten Modul-Graphen blockiert. Jede unnötige sequenzielle Millisekunde im Top-Level Await eines Core-Moduls pflanzt sich als Verzögerung in alle importierenden Module fort. Die Faustregel: Für unabhängige Promises immer Promise.all(), niemals sequenzielle await-Zeilen.


// WRONG: sequential awaits block for 200ms + 300ms = 500ms
const config = await loadConfig();    // 200ms
const db = await connectDatabase();  // 300ms — starts AFTER config done
// Total: 500ms — unnecessarily slow

// RIGHT: parallel with Promise.all — only 300ms (max of both)
const [config, db] = await Promise.all([
  loadConfig(),       // 200ms — runs in parallel
  connectDatabase(),  // 300ms — runs in parallel
]);
// Total: 300ms — optimal

// RIGHT: Promise.allSettled when partial failure is acceptable
const [configResult, flagsResult] = await Promise.allSettled([
  loadConfig(),
  loadFeatureFlags(),
]);

const config = configResult.status === "fulfilled"
  ? configResult.value
  : DEFAULT_CONFIG;

// RIGHT: race for timeout handling
const configWithTimeout = await Promise.race([
  loadConfig(),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Config load timeout")), 3000)
  ),
]);

6. Top-Level Await in Node.js ESM

Top-Level Await ist in Node.js ab Version 14.8 für ES-Module verfügbar — aber ausschließlich in .mjs-Dateien oder in Projekten mit "type": "module" in der package.json. CommonJS-Module (.cjs oder .js ohne Module-Flag) unterstützen kein Top-Level Await. Das ist eine fundamentale Einschränkung: CJS-Module sind synchron — sie können kein require() von einem ESM-Modul mit Top-Level Await durchführen, weil CJS nicht auf asynchrone Modul-Initialisierung warten kann.

Für Node.js-Server-Anwendungen ist das ein wichtiger Architekturpunkt: Wer Top-Level Await für Datenbankverbindungen und Konfigurationsladung nutzen will, muss konsequent auf ESM setzen. Die Migration von CJS zu ESM in bestehenden Node.js-Projekten ist schrittweise möglich — aber Top-Level Await ist ein starkes Argument dafür, diesen Schritt zu gehen. Der praktische Nutzen ist erheblich: Server-Startlogik, die bisher in verschachtelten then()-Ketten oder einer Bootstrap-async-Funktion steckte, lässt sich linear und lesbar am Top-Level des Einstiegspunkts schreiben.

7. Vergleich: async IIFE vs. Top-Level Await

Die async IIFE war der etablierte Workaround vor Top-Level Await und wird noch in vielen Codebasen gefunden. Der fundamentale Unterschied: Eine async IIFE startet die asynchrone Arbeit, aber das Modul setzt seine Initialisierung sofort fort — wenn es Exports hat, sind diese beim Import des Moduls möglicherweise noch nicht bereit. Top-Level Await pausiert die gesamte Modul-Evaluierung, bis die Promise erfüllt ist — Exports sind beim Import des Moduls garantiert bereit.

Aspekt async IIFE Top-Level Await Konsequenz
Export-Garantie Keine — Race Condition Garantiert bereit Kein undefined beim Import
Fehlerbehandlung Unhandled Promise Modul-Ladefehler Fehler im Import-Stack sichtbar
Modul-Graph-Semantik Keine Synchronisation Graph wartet korrekt Abhängigkeiten korrekt serialisiert
CJS-Kompatibilität Überall nutzbar Nur ESM ESM-Migration erforderlich
Lesbarkeit Verschachtelt, indirekt Linear, direkt Weniger Boilerplate

Die Fehlerbehandlung ist ein weiterer wichtiger Unterschied: Wenn eine async IIFE einen Fehler wirft, entsteht ein Unhandled Promise Rejection, das je nach Runtime unterschiedlich behandelt wird. Wenn ein Top-Level Await fehlschlägt, wird das als Modul-Ladefehler propagiert — alle Importeure des Moduls erhalten denselben Fehler, der sich durch den Modul-Graphen bis zum Einstiegspunkt fortpflanzt. Das macht den Fehler sichtbarer und klarer lokalisierbar.

8. Fehler, Deadlocks und was CJS-Imports dagegen haben

Ein kritischer Fallstrick von Top-Level Await sind zyklische Abhängigkeiten kombiniert mit Top-Level Awaits. Wenn Modul A Modul B importiert und auf ein Top-Level Await wartet, während Modul B Modul A importiert und ebenfalls auf ein Top-Level Await wartet, entsteht ein Deadlock: Beide Module warten aufeinander. JavaScript-Engines können solche Zyklen erkennen und werfen einen Fehler — aber die Fehlermeldung ist nicht immer intuitiv. Die Lösung: Zyklische Abhängigkeiten vermeiden, besonders in Modulen mit Top-Level Awaits.

CJS-Module können keine ESM-Module mit Top-Level Await über require() importieren — das ist technisch unmöglich, weil require() synchron ist und CJS keine asynchronen Modul-Ladevorgänge kennt. Wer von einem CJS-Modul auf ein ESM-Modul mit Top-Level Await zugreifen muss, muss import() (Dynamic Import, der eine Promise zurückgibt) nutzen und das Ergebnis in einer async-Funktion awaiten. Das ist eine der wichtigsten Migrationsbarrieren bei der CJS-zu-ESM-Umstellung.

9. Bundler-Unterstützung: Vite, Webpack und esbuild

Die Bundler-Unterstützung für Top-Level Await ist breit, aber mit Einschränkungen. Vite unterstützt Top-Level Await nativ im Development-Mode und in Production-Builds für moderne Browser-Targets ohne zusätzliche Konfiguration. Webpack 5 unterstützt Top-Level Await mit experiments: { topLevelAwait: true } in der Konfiguration und dem asyncWebAssembly oder outputModule-Experiment. esbuild unterstützt Top-Level Await für ESM-Output-Format, aber nicht für CJS oder IIFE — was konsistent mit der Semantik ist, da CJS kein Top-Level Await kennt.

Für Browser-Bundles gilt: Das Output-Format muss ES-Module sein. Viele Build-Pipelines, die noch auf IIFE oder UMD-Output setzen, können Top-Level Await nicht direkt unterstützen. Das ist ein weiteres Argument für die Umstellung auf native ES-Module als Bundler-Output — die Unterstützung in modernen Browsern ist seit Jahren stabil, und HTTP/2 macht das Chunking-Modell von nativen ES-Modulen in Verbindung mit Lazy Loading über Dynamic Import attraktiv.

10. Zusammenfassung

Top-Level Await in ES-Modulen löst ein reales Problem: Modul-Initialisierung, die von asynchroner Arbeit abhängt, war bisher nur mit fehleranfälligen Workarounds möglich. Mit Top-Level Await pausiert die Modul-Evaluierung an jeder await-Stelle und resumiert, wenn die Promise erfüllt ist. Exports sind beim Import eines solchen Moduls garantiert in ihrem fertigen Zustand — keine Race Conditions, keine Undefined-Exports, kein Boilerplate.

Die wichtigsten Einschränkungen: Nur in ES-Modulen verfügbar (kein CJS), zyklische Abhängigkeiten mit Top-Level Await können zu Deadlocks führen, und sequenzielle awaits für unabhängige Operationen kosten unnötig Zeit. Die Performance-Faustregel gilt absolut: Unabhängige async Operationen immer mit Promise.all() parallelisieren. Top-Level Await im kritischen Ladepfad sollte so schnell wie möglich abgeschlossen sein, weil die gesamte abhängige Modul-Hierarchie wartet.

Mironsoft

JavaScript-Architektur, ESM-Migration und Performance-Optimierung

CJS zu ESM migrieren und Top-Level Await einsetzen?

Wir begleiten die Migration von CommonJS zu ES-Modulen, identifizieren Race Conditions in der Initialisierungslogik und optimieren den Modul-Graphen für schnelle Ladezeiten.

ESM-Migration

CJS-zu-ESM-Migration mit schrittweiser Umstellung und vollständigen Regressionstests

Modul-Architektur

Zyklenanalyse, Dependency-Graph-Optimierung und Top-Level Await-Strategie

Build-Pipeline

Vite/Webpack-Konfiguration für Top-Level Await, Code-Splitting und Dynamic Import

Top-Level Await — Das Wichtigste auf einen Blick

Nur ESM, kein CJS

Top-Level Await funktioniert ausschließlich in ES-Modulen. CJS ist synchron und kann keine Modul-Initialisierung mit Top-Level Await awaiten — require() ist blockierend.

Export-Garantie

Exports eines Moduls mit Top-Level Await sind beim Import garantiert in ihrem fertigen Zustand. Kein Race Condition wie bei async IIFE und no undefined-Exports.

Promise.all() für Parallelität

Niemals sequenzielle await-Zeilen für unabhängige Operationen. Promise.all() parallelisiert und halbiert die Ladezeit. Besonders kritisch im Ladepfad.

Zyklen vermeiden

Zyklische Abhängigkeiten zwischen Modulen mit Top-Level Await können zu Deadlocks führen. Abhängigkeitsgraph bewusst designen — keine wechselseitigen Imports mit Awaits.

11. FAQ: Top-Level Await in JavaScript

1Was ist Top-Level Await?
await direkt im ES-Modul-Scope ohne async-Wrapper. Das Modul pausiert bis zur Promise-Erfüllung und setzt dann fort. Exports sind beim Import garantiert bereit.
2Warum nicht in CJS?
CJS ist synchron. require() blockiert und kann nicht auf asynchrone Modul-Initialisierung warten. Top-Level Await ist ESM-exklusiv.
3Performance-Falle: sequenzielle Awaits?
Immer Promise.all() für unabhängige Operationen. Zwei sequenzielle awaits von 200ms und 300ms blockieren 500ms — Promise.all() nur 300ms.
4Fehler in Top-Level Await?
Wird als Modul-Ladefehler propagiert — alle Importeure erhalten denselben Fehler. Transparenter als Unhandled Promise Rejection aus async IIFE.
5Dynamic Import + Top-Level Await?
const m = await import('./heavy.js') direkt im Modul-Scope — gültig. Ermöglicht bedingtes Code-Splitting und plattformabhängiges Implementierungs-Loading.
6Node.js-Version?
Ab Node.js 14.8 in .mjs oder "type":"module" Projekten. Für .cjs-Dateien nicht verfügbar — auch in neueren Node-Versionen nicht.
7Webpack 5 aktivieren?
experiments: { topLevelAwait: true } in webpack.config.js. Für Browser-Bundles zusätzlich experiments: { outputModule: true } für ESM-Output.
8Top-Level Await vs. async IIFE?
IIFE: Exports können beim Import undefined sein (Race Condition). Top-Level Await: Modul pausiert — Exports garantiert bereit beim Import. Klar überlegener Ansatz für ESM.
9Zyklische Abhängigkeiten mit Top-Level Await?
Können zu Deadlocks führen: A wartet auf B, B wartet auf A. Engine erkennt den Zyklus und wirft einen Fehler. Zyklen in Modulen mit Awaits grundsätzlich vermeiden.
10Wann Top-Level Await NICHT verwenden?
Nicht bei optionaler Initialisierung. Nicht in CJS-kompatiblen Libraries. Nicht bei langen Operationen ohne Promise.all() — der gesamte Modul-Graph wartet.