Async JavaScript - 15 - Top-level await

Comment fonctionne await au niveau module, ses implications sur le graphe d'import, et quand l'utiliser sans piège.

Async JavaScript - Top-level await

Ce que tu vas apprendre

  • Ce que signifie await en 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

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.