ES Modules (ESM)
ESM is the official JavaScript module standard (import/export). Node.js supports ESM in .mjs files or .js files when package.json has "type": "module". ESM imports are static and analyzed at parse time.
ESM = a shared Google Doc with live cursors. Everyone sees changes in real time (live bindings). CJS = emailing a PDF copy—you get whatever was written at send time and never see updates. ESM also scans the whole doc before opening (static analysis).
Unlike CJS, ESM imports are live bindings—if a module updates an exported value, importers see the update. ESM loading is asynchronous: the loader fetches, parses, and links modules before executing them (using a graph traversal). Top-level await is supported in ESM, enabling async initialization at the module level. To import a CJS module from ESM, use a default import; to import ESM from CJS, you must use dynamic import() since CJS require() is synchronous and can't await ESM loading.
ESM uses a three-phase loading process: (1) Construction—parse source, build module graph, find all imports; (2) Instantiation—allocate memory for exports, create live binding environment records; (3) Evaluation—execute module code. This means all imports are resolved before any module code runs, enabling static analysis for tree-shaking. Circular ESM dependencies work differently from CJS: because exports are live bindings (not copied values), circular imports don't return partially-constructed objects—they return the binding, which will be populated once evaluation completes. However, using an exported value before the providing module's code has run throws a ReferenceError (temporal dead zone for let/const exports).
ESM uses static import/export analyzed at parse time, enabling tree-shaking and top-level await. Imports are live bindings to the exporting module's values. Loading is asynchronous (three phases: construct, instantiate, evaluate). Mixing CJS and ESM requires care: CJS can't require() ESM (no sync await), but ESM can import CJS via default import. Use dynamic import() to load ESM from CJS.
ESM live bindings mean `import { count } from './counter.js'` gives you a live read-only view of counter.js's count variable. If counter.js increments count, your imported binding reflects the new value. This is fundamentally different from CJS where you get a copy of the value at require() time—and it breaks destructuring-based memoization patterns.