You have just mastered CommonJS: require, module.exports, the cache, the module wrapper. It is the system Node.js was born with and the one that an enormous amount of today's code runs on. But it is not JavaScript's module system.

While Node was solving the problem on its own in 2009, the committee that standardizes the language was working on an official solution. It arrived in 2015 with ES2015: ES modules (ESM), with import and export, defined inside the language and designed to work the same way in the browser and on the server. Node took years to support them stably, and today it lives with both.

This lesson is not a catalog of alternative syntax. The difference between the two systems is deep: CommonJS is dynamic and resolves while the program runs; ESM is static and resolves before a single line executes. Every other difference comes from that: why extensions are mandatory, why __dirname does not exist, why CommonJS cannot require ESM, and why only ESM has top-level await.

By the end you will know how to write ES modules, convert what you have built in Escena Viva, make both systems coexist in the same project knowing the real limits, and you will have a clear criterion for what to use and when.

Contents

  1. ES module syntax
  2. The essential difference: static versus dynamic
  3. A complete comparison table
  4. How ESM is enabled in Node
  5. Extensions are mandatory
  6. What does not exist in ESM and how to replace it
  7. Top-level await
  8. Dynamic import: await import()
  9. Interoperability in both directions
  10. The ESM version of the Escena Viva domain
  11. A practical criterion for the course

  1. ES module syntax

Named exports

The most common form, and the equivalent of our module.exports = { ... }:

// src/utils/format.mjs

// Option A: export in the declaration itself.
export function formatPrice(cents) {
  const euros = Math.floor(cents / 100);
  const remainder = String(cents % 100).padStart(2, '0');
  return `${euros}.${remainder} EUR`;
}

export function formatDate(isoDate) {
  const date = new Date(isoDate);
  const day = String(date.getDate()).padStart(2, '0');
  const month = String(date.getMonth() + 1).padStart(2, '0');
  return `${day}/${month}/${date.getFullYear()}`;
}

export const CURRENCY_SYMBOL = 'EUR';
// Option B: export as a block at the end. It matches the single module.exports
// of CommonJS and has the same advantage: an index of the public API.
function formatPrice(cents) { /* ... */ }
function formatDate(isoDate) { /* ... */ }
const CURRENCY_SYMBOL = 'EUR';

export { formatPrice, formatDate, CURRENCY_SYMBOL };

And when importing:

// Import what you need, by name.
import { formatPrice, formatDate } from './utils/format.mjs';

// Rename, to avoid collisions.
import { formatPrice as price } from './utils/format.mjs';

// Import EVERYTHING into a namespace.
import * as format from './utils/format.mjs';
console.log(format.formatPrice(2500));

// Import purely for the side effect (rare, but it exists).
import './global-config.mjs';

Default export

Each module can have one default export:

// src/domain/session.mjs
export default class Session {
  /* ... */
}
// When importing, you choose the name: no braces.
import Session from './domain/session.mjs';
import Whatever from './domain/session.mjs';   // Legal, and confusing

Both forms can be combined:

// src/domain/event.mjs
export default class Event { /* ... */ }
export const EVENT_STATUSES = ['draft', 'published', 'finished'];
import Event, { EVENT_STATUSES } from './domain/event.mjs';
Named Default
How many per module As many as you like One
Import syntax import { X } from ... import X from ...
Is the name fixed? Yes (except with as) No: the importer chooses it
Editor autocompletion Good Worse
Renaming in a refactor Propagates Has to be checked by hand

Recommendation for the course: use named exports, not export default. It is consistent with the module.exports = { ... } we already use, the editor autocompletes better, and it stops the same module showing up under five different names in five files. Many professional style guides (including Node's own and those of several large projects) have reached the same conclusion.

Re-exporting: export * from

The equivalent of our index.js facade:

// src/domain/index.mjs

// Re-export EVERYTHING named from each module.
export * from './session.mjs';
export * from './event.mjs';
export * from './sales-manager.mjs';

// Or be selective, which is usually better.
export { Session } from './session.mjs';
export { Event } from './event.mjs';
export { SalesManager, LOW_CAPACITY_THRESHOLD } from './sales-manager.mjs';

// Re-export under a different name.
export { Session as EventSession } from './session.mjs';

An important note: export * does not re-export the default export. If a module has export default, it has to be re-exported explicitly:

export { default as Session } from './session.mjs';

It is another reason to prefer named exports: facades work with no surprises.

  1. The essential difference: static versus dynamic

Everything else comes from here, so it is worth understanding properly.

CommonJS is dynamic

require is an ordinary function. It runs when the interpreter reaches it, and its argument can be any expression:

// All of this is legal in CommonJS.
const loaded = require('./' + moduleName + '.js');

if (process.env.MODE === 'production') {
  logger = require('./production-logger.js');
}

for (const name of ['a', 'b', 'c']) {
  modules[name] = require(`./plugins/${name}.js`);
}

function loadLazily() {
  const heavy = require('./very-heavy-module.js');   // Only if it is called
  return heavy.process();
}

Node cannot know which modules a CommonJS program needs without running it. That is flexible and it has a price.

ESM is static

import is not a function: it is a language declaration. Its path must be a string literal, and the declarations can only appear at the top level of the module.

// ALL of this is a SyntaxError in ESM.
import loaded from './' + name + '.mjs';          // Non-literal path

if (production) {
  import logger from './logger.mjs';              // Not inside a block
}

function load() {
  import heavy from './heavy.mjs';                // Not inside a function
}

Thanks to that rigidity, the engine can analyze the entire dependency graph before running anything. The process has three clearly separated phases:

flowchart TD
    subgraph ESM["ES modules: three phases"]
        A1["<b>1. Construction</b><br/>Every file is read and its<br/>import/export statements analyzed.<br/>The complete graph is built<br/><i>without running anything</i>"]
        A2["<b>2. Instantiation</b><br/>Space is reserved for each<br/>export and the references<br/>between modules are wired up"]
        A3["<b>3. Evaluation</b><br/>Each module's code is run,<br/>in dependency order"]
        A1 --> A2 --> A3
    end

    subgraph CJS["CommonJS: a single phase"]
        B1["<b>Execution</b><br/>The code runs top to bottom.<br/>Each require encountered<br/>loads and evaluates right then"]
    end

The consequences of that up-front analysis are very concrete:

Consequence Why it matters
Import errors at analysis time Importing something a module does not export fails before running, not halfway through a production request
Dead code elimination (tree-shaking) A bundler knows which exports are unused and can strip them. With require it is impossible to tell
Imports are hoisted Every import is processed before any of the module's code, wherever they are written
Bindings are live An import does not copy the value: it binds to the original variable

That last point is surprising and worth seeing:

// src/lab/counter.mjs
export let count = 0;
export function increment() {
  count++;
}
// src/lab/use-counter.mjs
import { count, increment } from './counter.mjs';

console.log(count);   // 0
increment();
console.log(count);   // 1  <-- The imported value CHANGED

With CommonJS, const { count } = require('./counter.js') would have copied the 0 and it would never change. In ESM, count is a read-only live binding to the other module's variable. You can read it and see its changes, but you cannot assign to it (count = 5 is a TypeError).

And imports are hoisted:

// This works in ESM, even though it looks impossible.
console.log(formatPrice(2500));            // 25.00 EUR
import { formatPrice } from './format.mjs';

The import is processed in phase 1, long before the console.log runs. It works, but always write your imports at the top: the language allowing it does not make it readable.

  1. A complete comparison table

Aspect CommonJS ES modules
Import syntax require('./m.js') import { x } from './m.js'
Export syntax module.exports = {...} export { x } / export default
File extension .js (or .cjs) .mjs, or .js with "type": "module"
Resolution Dynamic, at run time Static, before running
Import path Any expression String literal only
Can it import conditionally? Yes No (only with dynamic import())
Extension in relative paths Optional Mandatory
Loading Synchronous Asynchronous
Imported value A copy of module.exports A read-only live binding
this at the top level module.exports ({}) undefined
__dirname / __filename Available Do not exist (use import.meta.url)
require Available Does not exist (use createRequire)
import.meta Does not exist Available
Top-level await No Yes
Evaluation order When the require is reached Dependencies first, depth-first
Circular dependencies An incomplete object ({}) Uninitialized bindings (ReferenceError)
Strict mode Optional Always on
Loading JSON require('./d.json') directly Needs with { type: 'json' }
Dead code elimination No Yes
Browser compatible No Yes
Can it import the other system Cannot require ESM It can import CJS

Two rows deserve extra comment:

Strict mode always on. In ESM you do not need 'use strict': it is implicit. That means assigning to an undeclared variable throws an error, this in a loose function is undefined, and duplicate parameters are illegal. It is a good default.

Circular dependencies. In CommonJS you got an empty object and the failure showed up later, with a confusing message. In ESM, thanks to the static analysis, you get a ReferenceError: Cannot access 'X' before initialization at the exact point of the problem. It is far better: a clear, early error instead of an undefined travelling through your code.

  1. How ESM is enabled in Node

Node needs to know whether a .js file is CommonJS or ESM. There are two ways to tell it.

Option A: the file extension

Extension System When to use it
.mjs Always ESM An ESM file in a CommonJS project
.cjs Always CommonJS A CommonJS file in an ESM project
.js Depends on the nearest package.json The normal case

It is explicit and depends on nothing else. Ideal for introducing a single file of the other system.

Option B: the package.json type field

{
  "type": "module"
}

With that line, every .js in the project becomes an ES module. Without it (or with "type": "commonjs", which is the default), they are CommonJS.

Here we only care about that one line. The complete package.json — name, version, dependencies, scripts, exports — is the subject of Module 5. For now it is enough to know that a package.json file with that single field, at the root of your project, changes the interpretation of every .js.

The resolution rule

Node looks for the nearest package.json going upwards from the file it is about to load. That makes mixing possible:

escena-viva/
├── package.json              { "type": "module" }
├── src/
│   ├── catalog.js            -> ESM (from the root package.json)
│   ├── domain/
│   │   └── event.js          -> ESM
│   └── legacy/
│       ├── package.json      { "type": "commonjs" }
│       └── old.js            -> CommonJS (from ITS package.json)
└── tools/
    └── migrate.cjs           -> CommonJS (from the extension)

It is the mechanism that lets you migrate a large project piece by piece instead of all at once.

Checking it

# A .mjs file is always ESM
echo 'console.log(typeof require);' > test.mjs
node test.mjs
# ReferenceError: require is not defined in ES module scope

# The same content as .cjs
echo 'console.log(typeof require);' > test.cjs
node test.cjs
# function

  1. Extensions are mandatory

This is the number one stumble when migrating from CommonJS to ESM.

// CommonJS: all four forms work.
require('./session');
require('./session.js');
require('./domain');          // Finds domain/index.js
require('./domain/index.js');
// ESM: only COMPLETE paths work.
import { Session } from './session.js';         // RIGHT
import { Session } from './session';            // ERROR
import { Event } from './domain';               // ERROR: it does not look for index.js
import { Event } from './domain/index.js';      // RIGHT
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/joan/escena-viva/src/session'
imported from /home/joan/escena-viva/src/catalog.js
Did you mean to import ./session.js?

Node even suggests the fix, which is appreciated.

Why this rigidity? Because ESM is defined to work in the browser too, where an import './session' would force the browser to try session, session.js, session.json… each with an HTTP round trip. Unacceptable. The standard demands complete, unambiguous paths.

Two important nuances:

  • The rule only applies to relative and absolute paths. Packages from node_modules are still imported by name: import express from 'express' is correct, because the package itself declares its entry point.
  • index.js is not special in ESM. You have to write the full path. Our facades become import { Event } from './domain/index.js'.

A practical tip: always write the extensions in your CommonJS files as well — as we did throughout the previous lesson. The day you migrate, half the work will already be done.

  1. What does not exist in ESM and how to replace it

The five module wrapper variables you learned in the previous lesson do not exist in ESM. There is no wrapper: an ES module is a real module, defined in the language.

Does not exist Replacement
__dirname path.dirname(fileURLToPath(import.meta.url))
__filename fileURLToPath(import.meta.url)
require createRequire(import.meta.url) or await import()
module.exports export
exports export
require.main === module import.meta.url === pathToFileURL(process.argv[1]).href
require.cache No public equivalent API

import.meta

ESM brings its own object with metadata about the current module:

// src/lab/metadata.mjs
console.log(import.meta.url);
// file:///home/joan/escena-viva/src/lab/metadata.mjs

Notice it is a URL, not a filesystem path. That is consistent with the standard (in the browser it would be https://...), but it means you have to convert it before using it with fs or path.

Getting __dirname and __filename back

// src/utils/paths.mjs
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

// Exact equivalents of the CommonJS variables.
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

console.log(__filename);   // /home/joan/escena-viva/src/utils/paths.mjs
console.log(__dirname);    // /home/joan/escena-viva/src/utils

// The usual use: a reliable path to the data.
const DATA_PATH = join(__dirname, '..', '..', 'data', 'events.json');

In recent Node versions (20.11 and later) there is a shortcut:

const __dirname = import.meta.dirname;    // Directly, with no conversion
const __filename = import.meta.filename;

Check your version with node -v before using it; if your project must run on older versions, use the fileURLToPath form.

Getting require back

For the cases where you need a CommonJS module that does not import cleanly:

// src/lab/use-require.mjs
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

// Now it works as in CommonJS, JSON included.
const catalog = require('../data/events.json');
const legacyPackage = require('commonjs-only-package');

console.log(`${catalog.length} events`);

createRequire needs to know where to resolve relative paths from, which is why it receives import.meta.url.

Detecting whether you are the main program

// src/reports/occupancy.mjs
import { pathToFileURL } from 'node:url';

const isMainProgram = import.meta.url === pathToFileURL(process.argv[1]).href;

if (isMainProgram) {
  // Only with: node src/reports/occupancy.mjs
  runReport();
}

Less elegant than require.main === module, but equivalent. In Node 20.11+ there is also import.meta.main in some configurations; check your version's documentation.

Importing JSON

// Import attributes syntax (Node 20.10+ / 22+).
import catalog from '../data/events.json' with { type: 'json' };

console.log(catalog.length);

The with { type: 'json' } attribute is mandatory and it is a security measure: it prevents a malicious server from returning JavaScript where you expected data. As an alternative, you can always read the file with fs, which is what we will do in Escena Viva from Module 3 onwards.

  1. Top-level await

ESM's exclusive advantage, and one of the most convenient.

// src/lab/load.mjs
import { readFile } from 'node:fs/promises';

// await directly, without wrapping it in any async function.
const content = await readFile('data/events.json', 'utf8');
const catalog = JSON.parse(content);

console.log(`Loaded ${catalog.length} events`);

Compare it with what you had to write in CommonJS:

// CommonJS: you need a wrapper function and its .catch.
const { readFile } = require('node:fs/promises');

async function main() {
  const content = await readFile('data/events.json', 'utf8');
  const catalog = JSON.parse(content);
  console.log(`Loaded ${catalog.length} events`);
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

Its natural uses:

// 1. Asynchronous initialization before exporting.
const configuration = JSON.parse(await readFile('./config.json', 'utf8'));
export { configuration };

// 2. Choosing a dependency at run time.
const logger = process.env.NODE_ENV === 'production'
  ? await import('./production-logger.mjs')
  : await import('./development-logger.mjs');

// 3. Resources that must be ready before anyone uses the module.
const connection = await connectToDatabase();
export { connection };

How it works: a module with top-level await becomes an asynchronous module. Modules that import it will wait for it to finish before running. It is powerful and it has a cost: if that await takes five seconds, every importer waits five seconds. Use it for real initialization, not as a convenient shortcut in any old file.

And a warning: if the top-level await fails, the whole module fails to load and the application does not start. Wrap it in try/catch if you want to degrade gracefully.

  1. Dynamic import: await import()

import() as a function is the escape hatch that gives back require's flexibility without giving up static analysis. It returns a promise that fulfills with the module's namespace.

// It works in both ESM and CommonJS.
const loaded = await import('./domain/event.mjs');
console.log(loaded.Event);

// With destructuring.
const { Event } = await import('./domain/event.mjs');

Its three legitimate uses:

8.1 Conditional loading

// src/lab/conditional-load.mjs

const mode = process.env.NODE_ENV ?? 'development';

// The path can be an expression: here it can.
const { logger } = await import(`./loggers/${mode}.mjs`);

logger.info('Escena Viva starting up');

8.2 Lazy loading of heavy modules

// The ticket PDF generator is heavy and rarely used.
// We do not want to load it at every process startup.

export async function generateTicketPdf(ticket) {
  const { createPdf } = await import('./pdf/generator.mjs');
  return createPdf(ticket);
}

It is loaded the first time somebody generates a PDF, and from then on it stays cached.

8.3 Loading CommonJS from ESM (and ESM from CommonJS)

That is the interoperability bridge, and we look at it in the next section.

Static import Dynamic import()
Path A string literal Any expression
Where it can appear Top level Anywhere
Returns Live bindings A promise
Allows dead code elimination? Yes No
Works in CommonJS? No Yes

Do not overuse import(). Every dynamic import is a dependency tools cannot analyze. Use it when you have a reason (conditionality, size, interoperability), not out of habit.

  1. Interoperability in both directions

Here is the part that causes most frustration in real projects, and the rules are not symmetrical.

flowchart LR
    ESM["ES module<br/>(.mjs)"]
    CJS["CommonJS module<br/>(.cjs)"]

    ESM -->|"import ... from<br/><b>YES, with caveats</b>"| CJS
    CJS -->|"require<br/><b>NO</b>"| ESM
    CJS -.->|"await import()<br/><b>YES</b>"| ESM

9.1 ESM importing CommonJS: yes, with caveats

// src/utils/format.cjs  (CommonJS)
function formatPrice(cents) { /* ... */ }
function formatDate(isoDate) { /* ... */ }

module.exports = { formatPrice, formatDate };
// src/catalog.mjs  (ESM)

// The complete module.exports arrives as the DEFAULT export.
import format from './utils/format.cjs';
console.log(format.formatPrice(2500));   // Always works

// Named exports work... if Node manages to detect them.
import { formatPrice } from './utils/format.cjs';   // Usually works

The exact rule: a CommonJS module's module.exports always arrives as the default export. In addition, Node runs a static analyzer (cjs-module-lexer) over the file to try to detect the named exports and offer them too. That analysis is syntactic, it does not run the code, so it fails as soon as the exports are built dynamically:

// CommonJS with dynamic exports: the analyzer CANNOT detect them.
const functions = { formatPrice, formatDate };
for (const [name, fn] of Object.entries(functions)) {
  module.exports[name] = fn;
}
// From ESM:
import { formatPrice } from './format.cjs';
// SyntaxError: The requested module './format.cjs' does not provide
// an export named 'formatPrice'

The universal, always-safe solution:

// Import the whole object as the default and destructure afterwards.
import format from './utils/format.cjs';
const { formatPrice, formatDate } = format;

If an npm package gives you that error when you import it with braces, this is the answer.

9.2 CommonJS requiring ESM: no

// src/legacy.cjs
const { Event } = require('./domain/event.mjs');
Error [ERR_REQUIRE_ESM]: require() of ES Module
/home/joan/escena-viva/src/domain/event.mjs not supported.
Instead change the require of event.mjs to a dynamic import() which is
available in all CommonJS modules.

The reason is structural, not a whim: require is synchronous — it returns the value immediately — and ESM loading is asynchronous, because there can be top-level await anywhere in the dependency graph. There is no way for a synchronous function to return the result of an asynchronous process.

The solution is dynamic import, which does work in CommonJS:

// src/legacy.cjs
async function main() {
  const { Event } = await import('./domain/event.mjs');

  const event = new Event({ id: 'evt-001', title: 'Concierto de Otono' });
  console.log(event.toString());
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

The price: the function that does it has to become asynchronous, and that asynchrony propagates upwards. This is known as the "function coloring" problem, and it is why migrating a large project from CommonJS to ESM is more work than it looks.

A note on recent versions. Node 22 introduced require() of synchronous ES modules (with no top-level await) behind an experimental flag, and it has been stabilizing in later versions. It is a real improvement for migration, but do not take it for granted: it depends on the version, and the imported module cannot contain top-level await anywhere in its graph. The general rule to keep in your head is still the one above.

9.3 Interoperability summary

From To Does it work? How
ESM CommonJS Yes import x from './m.cjs' (default: always)
ESM CommonJS, named exports Almost always Depends on the static analysis; if it fails, import the default
ESM ESM Yes import { x } from './m.mjs'
CommonJS ESM with require No ERR_REQUIRE_ESM (except in recent, limited cases)
CommonJS ESM with import() Yes const m = await import('./m.mjs')
CommonJS CommonJS Yes require('./m.cjs')

  1. The ESM version of the Escena Viva domain

Let's translate what you built in the previous lesson. You will see that the logic does not change at all: only the input and output lines change.

src/utils/format.js

// ---------- CommonJS ----------
function formatPrice(cents) {
  const euros = Math.floor(cents / 100);
  const remainder = String(cents % 100).padStart(2, '0');
  return `${euros}.${remainder} EUR`;
}

function formatDate(isoDate) { /* ... */ }
function generateTicketCode(year, sequence) { /* ... */ }

module.exports = { formatPrice, formatDate, generateTicketCode };
// ---------- ES modules ----------
export function formatPrice(cents) {
  const euros = Math.floor(cents / 100);
  const remainder = String(cents % 100).padStart(2, '0');
  return `${euros}.${remainder} EUR`;
}

export function formatDate(isoDate) { /* ... */ }
export function generateTicketCode(year, sequence) { /* ... */ }

No final line: each function is exported where it is declared.

src/domain/session.js

// ---------- CommonJS ----------
const { formatPrice, formatDate } = require('../utils/format.js');

class Session {
  #sold = 0;
  /* ... the entire body, without a single change ... */
}

module.exports = { Session };
// ---------- ES modules ----------
import { formatPrice, formatDate } from '../utils/format.js';

export class Session {
  #sold = 0;
  /* ... the entire body, without a single change ... */
}

Private fields, getters, toJSON, validation: all identical. The module system does not touch the logic.

src/domain/sales-manager.js

// ---------- CommonJS ----------
const EventEmitter = require('node:events');

const LOW_CAPACITY_THRESHOLD = 0.10;

class SalesManager extends EventEmitter { /* ... */ }

module.exports = { SalesManager, LOW_CAPACITY_THRESHOLD };
// ---------- ES modules ----------
import { EventEmitter } from 'node:events';

export const LOW_CAPACITY_THRESHOLD = 0.10;

export class SalesManager extends EventEmitter { /* ... */ }

A real detail worth attention: in CommonJS we wrote require('node:events') and used the result directly as a class, because the events module exports the class as its module.exports. In ESM, the recommended form is the named import import { EventEmitter } from 'node:events'. Both work (the module offers both), but the named one is more explicit and is what you will see in modern documentation.

src/domain/index.js

// ---------- CommonJS ----------
const { Session } = require('./session.js');
const { Event } = require('./event.js');
const { SalesManager, LOW_CAPACITY_THRESHOLD } = require('./sales-manager.js');

module.exports = { Session, Event, SalesManager, LOW_CAPACITY_THRESHOLD };
// ---------- ES modules ----------
export { Session } from './session.js';
export { Event } from './event.js';
export { SalesManager, LOW_CAPACITY_THRESHOLD } from './sales-manager.js';

The ESM version is shorter and clearer: it re-exports directly, with no need to import first only to export again afterwards. It is one of the genuine improvements of the syntax.

src/catalog-data.js

// ---------- CommonJS ----------
const catalog = [ /* the 3 events */ ];

function getCatalog() {
  return structuredClone(catalog);
}

function getEventById(id) { /* ... */ }

module.exports = { getCatalog, getEventById };
// ---------- ES modules ----------
// The array stays PRIVATE: by not exporting it, nobody outside can touch it.
const catalog = [ /* the 3 events */ ];

export function getCatalog() {
  return structuredClone(catalog);
}

export function getEventById(id) { /* ... */ }

src/catalog.js

// ---------- CommonJS ----------
const { getCatalog } = require('./catalog-data.js');
const { Event } = require('./domain');                 // No extension: valid
const { formatPrice } = require('./utils/format.js');

function main() { /* ... */ }

if (require.main === module) {
  main();
}

module.exports = { readOptions, main };
// ---------- ES modules ----------
import { pathToFileURL } from 'node:url';
import { getCatalog } from './catalog-data.js';
import { Event } from './domain/index.js';             // Extension MANDATORY
import { formatPrice } from './utils/format.js';

export function main() { /* ... */ }

// The equivalent of require.main === module
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  main();
}

The three real changes in this file, and they are the three you will always see when migrating:

  1. ./domain → ./domain/index.js: the extension and the explicit index.js.
  2. require.main === module → the URL comparison.
  3. module.exports at the end → export on each declaration.

The ESM version that takes advantage of top-level await

And here is a real, not cosmetic, advantage. Getting ahead of what we will do in Module 3, this is how loading the catalog from disk will look:

// src/catalog-data.mjs  (a preview of module 3)
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_PATH = join(__dirname, '..', 'data', 'events.json');

// Top-level await: the catalog is loaded ONCE, when the module is imported.
// Whoever imports this module will get the data ready to use.
const catalog = JSON.parse(await readFile(DATA_PATH, 'utf8'));

export function getCatalog() {
  return structuredClone(catalog);
}

export function getEventById(id) {
  const event = catalog.find((e) => e.id === id);
  return event ? structuredClone(event) : undefined;
}

This has no equivalent in CommonJS. There you would have to choose between a blocking synchronous read (readFileSync) or exporting a promise every consumer would have to await. With ESM, the asynchronous initialization happens at module load, transparently to whoever uses it.

  1. A practical criterion for the course

What we will use

For the rest of the course we will keep using CommonJS as the main system, and these are the reasons:

  1. It is what you are going to run into. Millions of projects, countless tutorials and an enormous chunk of npm are still CommonJS. Being able to read it is not optional.
  2. Less friction while learning. require.main === module is simpler than comparing URLs, __dirname is just there with no ceremony, and you do not have to think about interoperability while learning fs or Express.
  3. Express and much of the classic ecosystem document their examples in CommonJS.

But every time a topic has a relevant ESM version, we will show it, and in Module 12 we will do the complete migration of Escena Viva as a closing exercise.

What to use in your new projects

Situation Recommendation
A new project from scratch ESM. It is the language standard and the future
A large existing CommonJS project Stay on CommonJS, or migrate piece by piece with .mjs
A library you are going to publish on npm Publish both formats (the exports field from Module 5)
Code that also runs in the browser ESM, no argument
A quick script with await at the top level ESM (or a standalone .mjs)
A config tool for another project Whatever that project expects

Why the ecosystem lives with both

It is not an accident nor collective laziness. There are underlying reasons:

  • Backwards compatibility is a value. Node cannot break millions of production projects overnight.
  • Migration is contagious. If your module moves to ESM, whoever consumed it from CommonJS can no longer use require. The pressure propagates upwards through the dependency tree.
  • Many tools expect CommonJS. Config files, plugins and some test runners still assume it.
  • The libraries' solution is to publish both formats (dual package), at the cost of a more complex setup and a real risk: loading two copies of the same library, one per system, with independent state.

The transition has taken almost a decade and has years to go. The professional skill is not picking a side: it is being able to work with both and recognize instantly which one you are looking at.

Common Mistakes and Tips

Mistake 1: omitting the extension in a relative import. ERR_MODULE_NOT_FOUND. It is the number one stumble when migrating. Always ./session.js.

Mistake 2: expecting ./domain to find index.js. There is no folder resolution in ESM. Write ./domain/index.js.

Mistake 3: using __dirname in an ESM file. ReferenceError. Use import.meta.dirname or fileURLToPath(import.meta.url).

Mistake 4: require() of an ES module. ERR_REQUIRE_ESM. Use await import() and accept that the function becomes asynchronous.

Mistake 5: importing with braces from a CommonJS package and having it fail. The analyzer did not detect the named exports. Import the default and destructure.

Mistake 6: adding "type": "module" to an existing project just like that. Every .js switches system at once and everything breaks. Migrate piece by piece with .mjs, or rename to .cjs whatever must stay as it is.

Mistake 7: top-level await in a module imported by many others. They all wait for it to finish. Reserve it for real initialization.

Mistake 8: overusing dynamic import(). You lose static analysis and dead code elimination. Only with a concrete reason.

Tip 1: write the extensions in CommonJS too. When you migrate, that work will already be done.

Tip 2: use named exports, not export default. Better autocompletion, consistent names and facades with no surprises.

Tip 3: learn to recognize the system in three seconds. See import? ESM. See require? CommonJS. Is it a .js? Check the nearest package.json.

Tip 4: when an npm package gives you import trouble, look at its package.json. The exports field (Module 5) and the type field tell you exactly what it offers.

Exercises

Exercise 1: migrating the domain to ESM

Create a copy of the project in escena-viva-esm/ and migrate to ES modules everything built in the previous lesson:

  1. A package.json containing only { "type": "module" }.
  2. src/utils/format.js, src/domain/session.js, src/domain/event.js, src/domain/sales-manager.js, src/domain/index.js, src/catalog-data.js and src/catalog.js.
  3. Every relative path with its extension, and an explicit ./domain/index.js.
  4. require.main === module replaced by the comparison with import.meta.url.
  5. src/reports/occupancy.js migrated and directly runnable.

Verify that node src/catalog.js --table produces exactly the same output as the CommonJS version, with the same totals (3000 capacity, 1811 sold, 1189 available). Note down how many changes you had to make and of what kind.

Exercise 2: interoperability in both directions

Create escena-viva-mixed/ with a project that experimentally demonstrates the rules of section 9:

  1. src/utils/format.cjs — CommonJS, with module.exports = { formatPrice, formatDate }.
  2. src/utils/dynamic-legacy.cjs — CommonJS that builds its exports dynamically in a loop.
  3. src/domain/session.mjs — ESM that imports format.cjs (which works) and tries to import with braces from dynamic-legacy.cjs (which fails), with the solution applied.
  4. src/report.cjs — CommonJS that needs the Session class from the ESM file. First demonstrate that require fails (catch the error and show its code) and then solve it with await import().
  5. A README.md with a table of which combination works, which does not and why.

Exercise 3: the catalog loader with top-level await

In an ESM project, write src/catalog-data.mjs that really takes advantage of what is exclusive to ESM:

  1. Loads data/events.json with readFile from node:fs/promises and top-level await, using import.meta.url to build a reliable path.
  2. Handles the read failure: if the file does not exist, it should log a clear warning on stderr and carry on with an empty catalog instead of preventing the application from starting.
  3. Exports getCatalog(), getEventById(id) and getSession(sessionId), all returning copies.
  4. Also exports a LOADED_AT constant with the ISO timestamp of the moment of loading, and demonstrates — by importing it from three different files — that the value is the same in all three: the module cache exists in ESM too.
  5. A src/main.mjs that uses all of the above with top-level await, with no wrapping main() function.

Answer: what would happen if the top-level await took 3 seconds? And if it threw an uncaught exception?

Solutions

Solution 1

// escena-viva-esm/package.json
{
  "type": "module"
}
// src/utils/format.js
export function formatPrice(cents) {
  const euros = Math.floor(cents / 100);
  const remainder = String(cents % 100).padStart(2, '0');
  return `${euros}.${remainder} EUR`;
}

export function formatDate(isoDate) {
  const date = new Date(isoDate);
  const day = String(date.getDate()).padStart(2, '0');
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const year = date.getFullYear();
  const hour = String(date.getHours()).padStart(2, '0');
  const minute = String(date.getMinutes()).padStart(2, '0');
  return `${day}/${month}/${year} ${hour}:${minute}`;
}

export function generateTicketCode(year, sequence) {
  return `EV-${year}-${String(sequence).padStart(6, '0')}`;
}
// src/domain/session.js
import { formatPrice, formatDate } from '../utils/format.js';

export class Session {
  #sold = 0;

  constructor({ id, dateTime, capacity, sold = 0, priceCents }) {
    this.id = id;
    this.dateTime = dateTime;
    this.capacity = capacity;
    this.priceCents = priceCents;
    this.#sold = sold;
  }

  get sold() { return this.#sold; }
  get available() { return this.capacity - this.#sold; }
  get occupancy() { return Math.round((this.#sold / this.capacity) * 100); }
  get soldOut() { return this.available === 0; }
  get priceEuros() { return (this.priceCents / 100).toFixed(2); }
  get revenueCents() { return this.#sold * this.priceCents; }

  sell(quantity = 1) {
    if (!Number.isInteger(quantity) || quantity < 1) {
      const error = new Error('The quantity must be a positive integer');
      error.code = 'INVALID_QUANTITY';
      throw error;
    }
    if (quantity > this.available) {
      const error = new Error(`Insufficient capacity in ${this.id}: ${this.available} left`);
      error.code = 'INSUFFICIENT_CAPACITY';
      throw error;
    }
    this.#sold += quantity;
    return this.#sold;
  }

  describe() {
    return (
      `${formatDate(this.dateTime)}  ${formatPrice(this.priceCents)}  ` +
      `${this.available}/${this.capacity} available  (${this.occupancy}% occupied)` +
      (this.soldOut ? '  [SOLD OUT]' : '')
    );
  }

  toJSON() {
    return {
      id: this.id,
      dateTime: this.dateTime,
      capacity: this.capacity,
      sold: this.#sold,
      priceCents: this.priceCents
    };
  }
}
// src/domain/index.js
export { Session } from './session.js';
export { Event } from './event.js';
export { SalesManager, LOW_CAPACITY_THRESHOLD } from './sales-manager.js';
// src/catalog.js  (only the parts that change)
import { pathToFileURL } from 'node:url';
import { getCatalog } from './catalog-data.js';
import { Event } from './domain/index.js';
import { formatPrice } from './utils/format.js';

export function readOptions(args) { /* identical */ }

export function main() { /* identical */ }

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  main();
}
node src/catalog.js --table
# IDENTICAL output to the CommonJS version
# 3 events | capacity 3000 | sold 1811 | available 1189 | revenue 62298.00 EUR

An inventory of changes. Migrating seven files required exactly four kinds of change, and none of them touched the logic:

Kind of change How many times Detail
const {...} = require(...) → import {...} from ... 9 Mechanical
module.exports = {...} → export on the declaration 7 One per file
require('./domain') → './domain/index.js' 2 Folder resolution does not exist
require.main === module → URL comparison 2 Plus import { pathToFileURL }

Zero changes to classes, private fields, getters, validations, calculations or formatting. It is the practical proof that the module system is infrastructure: changing it should not touch your business logic, and if it does, they were too tightly coupled.

Solution 2

// src/utils/format.cjs
// CommonJS with STATIC exports: Node's analyzer detects them.
function formatPrice(cents) {
  const euros = Math.floor(cents / 100);
  return `${euros}.${String(cents % 100).padStart(2, '0')} EUR`;
}

function formatDate(isoDate) {
  return isoDate.slice(0, 10).split('-').reverse().join('/');
}

module.exports = { formatPrice, formatDate };
// src/utils/dynamic-legacy.cjs
// CommonJS with DYNAMIC exports: the analyzer CANNOT detect them.
function calculateOccupancy(session) {
  return Math.round((session.sold / session.capacity) * 100);
}

function calculateAvailable(session) {
  return session.capacity - session.sold;
}

// Dynamic construction: only known at run time.
const functions = { calculateOccupancy, calculateAvailable };
for (const [name, fn] of Object.entries(functions)) {
  module.exports[name] = fn;
}
// src/domain/session.mjs
// ESM importing CommonJS in its two variants.

// 1. Static exports: the named import WORKS.
import { formatPrice } from '../utils/format.cjs';

// 2. Dynamic exports: the named one WOULD FAIL.
//    import { calculateOccupancy } from '../utils/dynamic-legacy.cjs';
//    SyntaxError: does not provide an export named 'calculateOccupancy'
//
//    Universal solution: import the default and destructure.
import legacy from '../utils/dynamic-legacy.cjs';
const { calculateOccupancy, calculateAvailable } = legacy;

export class Session {
  constructor({ id, capacity, sold = 0, priceCents }) {
    this.id = id;
    this.capacity = capacity;
    this.sold = sold;
    this.priceCents = priceCents;
  }

  describe() {
    return (
      `${this.id}  ${formatPrice(this.priceCents)}  ` +
      `${calculateAvailable(this)} available  (${calculateOccupancy(this)}%)`
    );
  }
}
// src/report.cjs
// CommonJS that needs a class defined in an ES module.

// 1. Demonstration that require FAILS.
try {
  const { Session } = require('./domain/session.mjs');
  console.log('This is not printed:', Session);
} catch (error) {
  console.error(`require failed with code: ${error.code}`);
  console.error(`  ${error.message.split('\n')[0]}`);
}

// 2. The solution with dynamic import.
async function main() {
  const { Session } = await import('./domain/session.mjs');

  const session = new Session({
    id: 'ses-002-1', capacity: 120, sold: 118, priceCents: 1800
  });

  console.log('');
  console.log('With await import() it DOES work:');
  console.log(`  ${session.describe()}`);
}

main().catch((error) => {
  console.error(`Failure: ${error.message}`);
  process.exitCode = 1;
});
node src/report.cjs
require failed with code: ERR_REQUIRE_ESM
  require() of ES Module /home/joan/escena-viva-mixed/src/domain/session.mjs not supported.

With await import() it DOES work:
  ses-002-1  18.00 EUR  2 available  (98%)

The README.md table:

From To Syntax Does it work? Reason
.mjs .cjs with static exports import { x } from Yes The analyzer detects the names
.mjs .cjs with dynamic exports import { x } from No The analyzer is syntactic, it does not run the code
.mjs .cjs with dynamic exports import m from + destructure Yes The complete module.exports always arrives as the default
.cjs .mjs require() No require is synchronous; ESM loading is asynchronous
.cjs .mjs await import() Yes It returns a promise, compatible with asynchronous loading

Solution 3

// src/catalog-data.mjs
// Loading the catalog with top-level await. Exclusive to ESM.

import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_PATH = join(__dirname, '..', 'data', 'events.json');

// 1 and 2. Loading with top-level await and graceful degradation on failure.
let catalog = [];

try {
  const content = await readFile(DATA_PATH, 'utf8');
  catalog = JSON.parse(content);
  console.error(`[catalog] loaded ${catalog.length} events from ${DATA_PATH}`);
} catch (error) {
  if (error.code === 'ENOENT') {
    console.error(`[catalog] WARNING: ${DATA_PATH} not found. Empty catalog.`);
  } else if (error instanceof SyntaxError) {
    console.error(`[catalog] WARNING: the file is not valid JSON (${error.message}).`);
  } else {
    console.error(`[catalog] WARNING: read failure (${error.message}).`);
  }
  // We do not rethrow: the application starts with an empty catalog.
}

// 4. Timestamp of the moment of loading.
export const LOADED_AT = new Date().toISOString();

// 3. Data access, always with copies.
export function getCatalog() {
  return structuredClone(catalog);
}

export function getEventById(id) {
  const event = catalog.find((e) => e.id === id);
  return event ? structuredClone(event) : undefined;
}

export function getSession(sessionId) {
  for (const event of catalog) {
    const session = event.sessions.find((s) => s.id === sessionId);
    if (session) {
      return structuredClone({ event: { id: event.id, title: event.title }, session });
    }
  }
  return undefined;
}
// src/consumer-a.mjs
import { LOADED_AT, getCatalog } from './catalog-data.mjs';

export function report() {
  return { module: 'consumer-a', loadedAt: LOADED_AT, events: getCatalog().length };
}
// src/consumer-b.mjs
import { LOADED_AT, getEventById } from './catalog-data.mjs';

export function report() {
  const event = getEventById('evt-002');
  return { module: 'consumer-b', loadedAt: LOADED_AT, title: event?.title ?? '(no data)' };
}
// src/main.mjs
// No wrapper function: top-level await throughout the file.

import { setTimeout as sleep } from 'node:timers/promises';
import { LOADED_AT, getCatalog, getSession } from './catalog-data.mjs';
import { report as reportA } from './consumer-a.mjs';
import { report as reportB } from './consumer-b.mjs';

const events = getCatalog();

console.log('CATALOG');
console.table(events.map((e) => ({
  id: e.id,
  title: e.title,
  venue: e.venue,
  sessions: e.sessions.length,
  capacity: e.sessions.reduce((t, s) => t + s.capacity, 0),
  sold: e.sessions.reduce((t, s) => t + s.sold, 0)
})));

const found = getSession('ses-002-1');
console.log('');
console.log(`Session ses-002-1: ${found.event.title}, ` +
  `${found.session.capacity - found.session.sold} available`);

// A pause with top-level await: impossible in CommonJS.
await sleep(50);

// 4. The timestamp is the SAME in all three modules.
console.log('');
console.log('MODULE CACHE IN ESM');
console.table([
  { module: 'main', loadedAt: LOADED_AT, data: `${events.length} events` },
  { ...reportA(), data: `${reportA().events} events` },
  { ...reportB(), data: reportB().title }
]);

const allEqual = LOADED_AT === reportA().loadedAt && LOADED_AT === reportB().loadedAt;
console.log('');
console.log(`Same timestamp in all three modules? ${allEqual}`);
[catalog] loaded 3 events from /home/joan/escena-viva-esm/data/events.json
CATALOG
┌─────────┬───────────┬─────────────────────────────────┬────────────────────┬──────────┬──────────┬──────┐
│ (index) │ id        │ title                           │ venue              │ sessions │ capacity │ sold │
├─────────┼───────────┼─────────────────────────────────┼────────────────────┼──────────┼──────────┼──────┤
│ 0       │ 'evt-001' │ 'Concierto de Otono'            │ 'Teatro Almendra'  │ 2        │ 840      │ 276  │
│ 1       │ 'evt-002' │ 'Noche de Monologos'            │ 'Sala Boveda'      │ 3        │ 360      │ 175  │
│ 2       │ 'evt-003' │ 'Festival de Jazz de Primavera' │ 'Auditorio Ribera' │ 2        │ 1800     │ 1360 │
└─────────┴───────────┴─────────────────────────────────┴────────────────────┴──────────┴──────────┴──────┘

Session ses-002-1: Noche de Monologos, 2 available

MODULE CACHE IN ESM
┌─────────┬──────────────┬────────────────────────────┬──────────────────────┐
│ (index) │ module       │ loadedAt                   │ data                 │
├─────────┼──────────────┼────────────────────────────┼──────────────────────┤
│ 0       │ 'main'       │ '2026-08-11T18:42:07.331Z' │ '3 events'           │
│ 1       │ 'consumer-a' │ '2026-08-11T18:42:07.331Z' │ '3 events'           │
│ 2       │ 'consumer-b' │ '2026-08-11T18:42:07.331Z' │ 'Noche de Monologos' │
└─────────┴──────────────┴────────────────────────────┴──────────────────────┘

Same timestamp in all three modules? true

Answers to the questions:

  • If the top-level await took 3 seconds, the three modules that import catalog-data.mjs would wait those 3 seconds before running their first line, and main.mjs would not start until then. The wait propagates through the whole dependency graph. That is why top-level await should be reserved for essential initialization: if the catalog could be loaded lazily or in the background, it would be better to export an async loadCatalog() function and let each caller decide when to wait.
  • If the await threw an uncaught exception, the whole module would fail to evaluate. Since ES modules are instantiated before anything runs, the application would not start at all: the error would propagate to every importer and the process would die with ERR_MODULE_NOT_FOUND or the original error. Hence the try/catch in the solution: it turns a fatal startup failure into a warning on stderr and an empty catalog. The application starts, reports the problem and runs in degraded mode, which is almost always preferable to not starting at all.

And the final conclusion, visible in the table: the module cache exists in ESM too. The three modules share the same timestamp because catalog-data.mjs was evaluated only once. The mechanism is different inside — a module registry keyed by URL instead of require.cache — but the observable property is identical: a module is a singleton within its process.

Conclusion

You have closed off the module system. You now know ES modules: named and default export, import with its variants, export * from for facades, and the recommendation to always prefer named exports for autocompletion, consistency and surprise-free re-exporting.

Above all, you understand the difference that explains everything: CommonJS is dynamic — require is a function that runs when the interpreter reaches it, with whatever path you like — while ESM is static: imports are declarations with literal paths, and the engine builds the complete graph, instantiates it and only then evaluates it. Every consequence flows from that: import errors caught before running, dead code elimination, hoisted imports, live bindings instead of copies, and top-level await.

You know how to enable it both ways — "type": "module" in the package.json, or the .mjs and .cjs extensions, which let you migrate a project piece by piece — and you know the new rules: extensions are mandatory in relative paths, index.js stops being special, strict mode is always on, and the five wrapper variables disappear, replaced by import.meta.url with fileURLToPath for __dirname, createRequire for require and pathToFileURL(process.argv[1]) to detect the main program.

You have mastered interoperability and its asymmetries: ESM can import CommonJS — always as the default, and with named exports when the static analyzer manages to detect them — but CommonJS cannot require ESM, because require is synchronous and ESM loading is asynchronous; the way through is await import(), at the cost of making the function that does it asynchronous.

And you have translated the whole of Escena Viva. The exercise's inventory was revealing: four kinds of mechanical change, zero changes to the logic. The Session and Event classes, their private fields, their getters, the SalesManager with its events: all identical. The module system is infrastructure, and well-separated logic never notices that it changed.

With this we close Module 2, the most conceptual of the course. You no longer see Node as a black box: you know its layers — V8, libuv, the bindings, the standard library — the four-thread thread pool and why blocking the main thread stops the entire server; you can predict line by line the execution order of any asynchronous program by walking the six phases of the event loop and its two priority queues; you handle the three forms of asynchrony — error-first callbacks, promises with async/await, and events with EventEmitter — and you know when each one applies; and you understand the two module systems the ecosystem lives with.

Escena Viva has stopped being a script with embedded data. It has src/domain/ with Session, Event and SalesManager, a facade in index.js, formatting utilities with no duplication, a data layer in catalog-data.js with a signature already prepared to become asynchronous, and a catalog.js that only reads arguments and presents. The totals are still the seed ones: 3000 capacity, 1811 sold, 1189 available.

And there is the last outstanding piece, the one that has been promised since Module 1: the catalog still lives inside the code. The data/events.json file has existed since the first lesson and we have not read it from the application even once. In Module 3: File System and I/O that changes. You will learn to read and write files with fs, to build paths that work on any operating system with path, and to process data/sales.csv with streams without loading it entirely into memory — because a season's sales file does not fit in V8's heap. Everything you have learned here about the event loop, promises and EventEmitters stops being theory at that moment: fs.promises are promises, streams are EventEmitters, and the difference between readFile and readFileSync is exactly the difference between a server that responds and one that freezes.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved