Le discours habituel, vous le connaissez : "ESM est le futur. Migrez." Très bien.
Mais personne, vraiment personne, ne prend le temps d'écrire l'article qui fait mal : ESM et CommonJS ne sont pas deux versions du même système. il n'y a pas un pont entre ces deux mondes. Il y a un abîme. Un abîme que toute la communauté Node.js préfère enjamber en regardant ailleurs.
Ce sont deux systèmes fondamentalement incompatibles qui coexistent sur la même plateforme, depuis des années, sans que personne ne soit vraiment honnête à ce sujet.
Je vais être honnête, moi.
La première chose que la plupart des guides vous diront, c'est que la différence entre require() et import est principalement syntaxique. Une nouvelle façon d'écrire la même chose. Plus moderne. Plus propre.
C'est faux.
require() et import ne fonctionnent pas de la même façon. Ils ne pensent pas de la même façon. Et cette différence de pensée, si vous me permettez cette métaphore, change tout ce qui se passe au moment où votre code est chargé, résolu, puis exécuté.
Dans CommonJS, require() est une fonction comme n'importe quelle autre. Elle s'exécute au moment où votre code la rencontre. Vous pouvez la mettre n'importe où.
// CommonJS = résolution dynamique
function loadFeature(nom) {
if (nom === 'analytics') {
return require('./analytics'); // ← exécuté ici, maintenant, à cet instant
}
return require('./placeholder');
}Le système ne sait rien de ce que votre code va importer avant de le faire tourner. C'est totalement libre. Totalement imprévisible pour le runtime.
ESM, c'est l'exact opposé.
// ESM = résolution statique
import { analytics } from './analytics.js';Tous les import sont résolus avant que votre code ne commence à s'exécuter. Le moteur lit votre fichier, construit le graphe complet de dépendances, qui importe quoi, qui importe quoi à son tour, et seulement après, il exécute.
Vous voyez où je veux en venir ? Ce n'est pas une préférence esthétique. C'est une philosophie entière de comment un système de modules doit fonctionner. Et ces deux philosophies coexistent, dans le même runtime, sans aucun compromis possible entre elles.
C'est là que la première fracture apparaît. C'est là que ça devient fascinant. Et vraiment, vraiment déroutant…
Quand un module CommonJS exporte une valeur et qu'un autre module la récupère, Vous obtenez un instantané. Une photo prise au moment du require(). Si le module source change count plus tard, vous ne le saurez jamais. Votre copie est gelée.
// counter.cjs
let count = 0;
function increment() { count++; }
module.exports = { count, increment };
// app.cjs
const { count, increment } = require('./counter.cjs');
increment();
console.log(count); // 0. Toujours 0.En ESM, la mécanique est entièrement différente.
// counter.mjs
export let count = 0;
export function increment() { count++; }
// app.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1. Toujours à jour.Vous n'obtenez pas une copie. Vous obtenez une référence vivante. Un regard direct sur la variable dans le module qui la déclare. Elle change, vous le voyez immédiatement.
Deux modèles de partage de données. Diamétralement opposés. Coexistant dans le même écosystème, parfois dans le même projet, parfois dans le même fichier.
Je pense que c'est la source de la moitié des bugs mystérieux que les développeurs rencontrent à la jonction entre les deux systèmes. Pas parce qu'ils ne sont pas bons. Parce que les deux systèmes leur promettent de faire "à peu près" la même chose. Mais ils ne le font pas du tout de la même façon.
ESM introduit une capacité que CJS n'a jamais eu. Et que personne ne mentionne quand on parle de la migration.
Le top-level await.
// config.mjs
const response = await fetch('https://api.example.com/config');
export const config = await response.json();Un module peut être asynchrone à la racine. Il peut attendre une réponse réseau, une lecture fichier, une connexion base de données, avant même de terminer son initialisation. Et puisque ESM résout statiquement ses dépendances, tous les modules qui dépendent de config.mjs doivent attendre que ce fetch se termine avant de pouvoir s'initialiser à leur tour.
C'est élégant, en théorie.
En pratique, si vous créez un cycle de dépendances avec du top-level await, vous pouvez créer ce que l’on nomme souvent un deadlock. Pas un warning. Pas une erreur claire avec une trace de stack. Un silence.
Node.js ne va pas vous crier dessus. Il va juste... attendre.
CommonJS ne peut jamais faire ça. Il ne peut pas être asynchrone à la racine. C'est une limitation, oui. Mais c'est aussi une protection structurelle que beaucoup de développeurs n'apprécieront qu'après avoir vécu ce silence dans leur terminal.
On en parle aussi comme d'un problème transitoire, ce fameux dual package hazard. "Ah oui, le dual package hazard, ça va se résoudre à mesure que les packages migrent vers ESM."
Non. Ça ne se résoudra pas.
Le dual package hazard arrive quand un package shippe à la fois une version CJS et une version ESM. Et selon le chemin par lequel votre code, ou vos dépendances, le charge, vous vous retrouvez avec deux copies différentes du même package chargées simultanément dans votre processus Node.
node_modules/
package-x/
index.cjs ← la version que require() charge
index.mjs ← la version que import chargeCe sont deux fichiers. Deux exécutions séparées. Deux lots d'état interne.Et voilà où ça devient vraiment inconfortable. Imaginez ce scénario :
// Votre code — un module ESM
import { createSession } from 'package-x'; // charge index.mjs
const session = createSession();
// Une dépendance de votre projet — un module CJS
// À l'intérieur : const { isValidSession } = require('package-x'); ← charge index.cjsVotre dépendance CJS appelle isValidSession(session). Elle retourne false.
Pas parce que la session est invalide. Parce que la fonction isValidSession dans la version CJS n'a jamais vu les instances créées par la version ESM. Pour elle, ce sont des objets inconnus. Deux univers séparés, partageant le même nom.
C'est le moment où vous passez trois heures à déplacer des console.log dans le vide.
Ce n'est pas un problème d'implémentation à corriger. C'est une conséquence logique et inévitable d'avoir deux systèmes de modules avec des sémantiques fondamentalement différentes coexistant sur la même plateforme. On ne peut pas "fixer" ça. On ne peut qu'apprendre à vivre avec.
Pour ma part, j’aurais envie de vous dire que si vous démarrez un nouveau projet, choisissez ESM. Sans discussion. Vous évitez une tonne de problèmes futurs et vous vous positionnez du bon côté de la fragmentation.
Si vous maintenez un projet CJS existant, la migration vers ESM n'est pas un simple find-and-replace. Soyez en conscient. Chaque package que vous utilisez se comporte différemment selon le contexte dans lequel il est chargé. Chaque changement de système de modules change la façon dont vos données sont partagées entre modules, des snapshots à des live bindings, ou l'inverse. C'est une réflexion architecturale, pas une tâche de refactoring.
Et si vous créez un package npm, c'est le choix le plus inconfortable. CJS-only ? Vous fermez la porte aux projets ESM-only qui ne peuvent pas vous require(). ESM-only ? Vous cassez tous les projets CJS qui dépendent de vous. Les deux ? Vous ouvrez la porte au dual package hazard.
Il n'y a pas de bon choix. Juste des choix moins mauvais que d'autres.
On vous dit que ESM est le futur de Node.js. Peut-être qu'il en fait partie.
Mais le futur n'est pas toujours propre. Parfois, il arrive avec des fractures qu'on ne demande pas. Et parfois, la chose la plus honnête qu'on puisse faire, c'est de les regarder en face, plutôt que de prétendre qu'elles vont s'effacer d'elles-mêmes.