Async JavaScript - Top-level await
Ce que tu vas apprendre
- Ce que signifie
awaiten dehors d'une fonction async - Pourquoi ca ne marche qu'avec les modules ESM
- Comment le module devient implicitement asynchrone
- Ce qui se passe pour les modules qui importent un module async
- Les cas d'usage legitimes : config, connexion DB, feature détection
- Les pièges : bloquer le graphe de modules, TTFB degrade
- Quand utiliser top-level await et quand l'éviter
Prerequisites
- 02 - Promises : comprendre les bases de Promise et async/await
- Savoir ce qu'est un module ESM (import/export)
Pendant longtemps, pour charger une config depuis un fichier JSON avant de démarrer une app Node.js, je faisais ca :
javascript// config.mjs - Avant top-level await
let config;
export async function loadConfig() {
const response = await fetch("/config.json");
config = await response.json();
return config;
}
export function getConfig() {
if (!config) throw new Error("Config pas chargee, appelle loadConfig() d'abord");
return config;
}
L'API etait penible. Chaque fichier qui avait besoin de la config devait vérifier qu'elle etait chargee, ou pire, appeler loadConfig() lui-meme en esperant qu'un autre module ne l'avait pas deja fait. Depuis ES2022, il y a mieux.
await en dehors d'une fonction async
Top-level await, c'est exactement ce que le nom dit : utiliser await directement au niveau racine d'un module, sans l'envelopper dans une async function.
javascript// config.mjs - Avec top-level await
const response = await fetch("/config.json");
export const config = await response.json();
C'est tout. Quand un autre module fait import { config } from "./config.mjs", il recoit directement l'objet config deja résolu. Pas de fonction d'initialisation, pas de vérification, pas de timing fragile.
Pourquoi seulement les modules ESM ?
Top-level await ne fonctionne pas dans un script classique (<script> sans type="module") ni dans CommonJS (require()). La raison est technique : les scripts classiques sont synchrones par définition. Les modules ESM, eux, sont deja charges de facon asynchrone par le navigateur.
html<!-- Ca ne marche PAS -->
<script>
const data = await fetch("/api"); // SyntaxError
</script>
<!-- Ca marche -->
<script type="module">
const data = await fetch("/api"); // OK
</script>
En Node.js, tu dois utiliser des fichiers .mjs ou avoir "type": "module" dans ton package.json.
Le module devient asynchrone
Quand tu mets un await au top level d'un module, ce module devient implicitement une Promise. L'import est toujours syntaxiquement le meme (import ... from ...), mais sous le capot, le moteur attend la résolution de la Promise avant de considérer le module comme "pret".
Graphe de modules :
main.mjs
|
+-- config.mjs (top-level await -> asynchrone)
+-- utils.mjs (synchrone)
+-- app.mjs
|
+-- config.mjs (deja en cours de resolution)
Le moteur construit le graphe de dépendances, identifié les modules asynchrones, et orchestre le chargement dans le bon ordre. utils.mjs est évalué immédiatement. config.mjs est évalué de facon asynchrone. app.mjs, qui importe config.mjs, attend que celui-ci soit résolu avant de s'évaluer. main.mjs attend que tous ses imports directs soient resolus.
Ce qui se passe pour les modules importants
C'est la partie subtile. Si un module A utilise top-level await, tous les modules qui importent A sont aussi bloques jusqu'a la résolution du await.
javascript// db.mjs
const connection = await connectToDatabase(); // Prend 2 secondes
export { connection };
// auth.mjs - ne depend PAS de db.mjs
export function verifyToken(token) { /* ... */ }
// server.mjs
import { connection } from "./db.mjs"; // Bloque 2 secondes
import { verifyToken } from "./auth.mjs"; // S'execute en parallele de db.mjs
// Mais server.mjs ne demarre qu'une fois les DEUX imports resolus
Bonne nouvelle : les modules freres (ceux au meme niveau) sont charges en parallèle. db.mjs et auth.mjs sont evalues simultanément. Mais server.mjs doit attendre que les deux soient prets.
La mauvaise nouvelle : si tu chaînes des modules asynchrones en cascade, les delais s'additionnent.
javascript// a.mjs : await (1 seconde)
// b.mjs : import a.mjs + await (1 seconde) -> 2 secondes au total
// c.mjs : import b.mjs + await (1 seconde) -> 3 secondes au total
C'est un waterfall asynchrone au niveau du graphe de modules. Exactement le meme problème que les await sequentiels dans une fonction, mais a l'échelle de toute l'application.
Cas d'usage legitimes
Chargement de configuration
javascript// config.mjs
const env = process.env.NODE_ENV || "development";
const raw = await import(`./config.${env}.json`, { with: { type: "json" } });
export const config = raw.default;
Connexion base de donnees
javascript// db.mjs
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGO_URI);
await client.connect();
export const db = client.db("myapp");
Feature détection
javascript// polyfills.mjs
if (!globalThis.structuredClone) {
const { default: clone } = await import("./structuredClone-polyfill.mjs");
globalThis.structuredClone = clone;
}
Initialisation conditionnelle
javascript// cache.mjs
let cache;
if (process.env.REDIS_URL) {
const { createClient } = await import("redis");
cache = createClient({ url: process.env.REDIS_URL });
await cache.connect();
} else {
cache = new Map();
}
export { cache };
Les pièges
Bloquer le graphe entier
javascript// analytics.mjs - MAUVAIS
// Ce module n'a pas besoin de bloquer le demarrage
const userData = await fetch("/api/user-preferences");
export const prefs = await userData.json();
Si ce module est importe depuis main.mjs, tout le démarrage de l'app attend les préférences utilisateur. Pour un module non critique, c'est un coût que tu ne veux pas payer.
Erreurs non gerees au niveau module
javascript// db.mjs
// Si ca echoue, le module entier echoue
// Et tous les modules qui l'importent aussi
const connection = await connectToDatabase(); // Throw si le serveur est down
Pas de try/catch au top level = un crash complet du graphe de modules. Ajoute toujours une gestion d'erreur :
javascript// db.mjs - mieux
let connection;
try {
connection = await connectToDatabase();
} catch (err) {
console.error("Connexion DB echouee, fallback en memoire", err);
connection = createInMemoryStore();
}
export { connection };
Impact sur le Time To Interactive
Dans le navigateur, chaque top-level await repousse le moment ou le module est pret. Si ton module principal fait un await fetch(...), rien ne s'affiche tant que le fetch n'a pas repondu. Pour une app client, préféré initialiser tes donnees apres le premier rendu.
Quand utiliser vs quand éviter
Utiliser :
- Modules de configuration serveur (Node.js)
- Connexions base de donnees
- Feature détection et chargement conditionnel de polyfills
- Scripts CLI qui ont besoin de donnees avant de démarrer
Éviter :
- Modules importes par beaucoup d'autres modules (risque de cascade)
- Chargement de donnees non critiques dans le navigateur
- Quand un pattern de lazy initialization suffit
- Quand le delai d'attente est imprevisible (API distante)
Résumé
Top-level await permet d'utiliser await directement a la racine d'un module ESM, en transformant implicitement ce module en Promise. Les modules qui l'importent attendent sa résolution de facon transparente. C'est puissant pour le chargement de config, les connexions DB et la feature détection. Mais il faut etre conscient de l'impact sur le graphe de modules : les await en cascade creent des waterfalls, les erreurs non gerees cassent tout le graphe, et dans le navigateur, chaque top-level await retarde le rendu. Utilise-le quand l'initialisation synchrone est impossible, pas comme raccourci pour éviter une fonction d'initialisation.
Retrouve d'autres articles sur l'asynchrone et JavaScript sur paltemps.fr.
Navigation : Precedent : 14 - requestAnimationFrame et requestIdleCallback | Suivant : 16 - Debugger l'asynchrone
Sources
- TC39 - Top-level await proposal par TC39
- V8 - Top-level await par V8 team
- MDN - import par Mozilla