ES Modules: import/export and Live Bindings
The Module System JavaScript Deserved
CommonJS was a brilliant hack — a synchronous module system bolted onto a language that didn't have one. But it was always a runtime pattern, not a language feature. The engine couldn't analyze your dependencies at parse time, bundlers had to guess at tree shaking, and browsers couldn't use it at all.
ES Modules (ESM) changed everything. Imports and exports are part of the language grammar. The engine sees your dependency graph before a single line of code runs. This enables static analysis, reliable tree shaking, and native browser support. If CommonJS was a post office delivering packages, ESM is a fiber-optic network with every connection mapped before the first byte flows.
Think of CommonJS exports as photocopies — you get a copy of the value at the moment you require it. Changes to the original don't update your copy. ES module exports are live windows — you're looking at the actual variable in the exporting module. When it changes, you see the change instantly. The exporting module controls what you see (read-only from the importer's perspective), but you're always seeing the current value.
Static Structure — The Foundation of Everything
The most important thing about ESM is that imports and exports are statically analyzable. The engine knows your entire dependency graph at parse time, before executing anything:
// These are all parse-time declarations — NOT runtime calls
import { readFile } from 'node:fs/promises';
import defaultExport from './utils.js';
import * as math from './math.js';
// This is NOT valid — import must be at the top level
if (condition) {
import { something } from './module.js'; // SyntaxError!
}
// This is NOT valid — the specifier must be a string literal
const name = './module.js';
import { something } from name; // SyntaxError!
This static structure is why ESM enables tree shaking. A bundler can look at your import statements without running any code and determine exactly which exports are used. With CommonJS, require() is just a function call — it could be called conditionally, with computed strings, inside loops. The bundler can't know what's needed without running the code.
Named Exports and Default Exports
ESM gives you two ways to export: named and default.
Named exports — the workhorse
// math.js — exporting individual bindings
export const PI = 3.14159265359;
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// or batch-export at the bottom
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;
export { multiply, divide };
// you can even rename on export
export { multiply as mul, divide as div };
// importing named exports
import { add, subtract } from './math.js';
import { add as sum } from './math.js'; // rename on import
import * as math from './math.js'; // namespace import
math.add(2, 3);
Default exports — one per module
// logger.js
export default function log(message) {
console.log(`[${new Date().toISOString()}] ${message}`);
}
// importing default exports — you choose the name
import log from './logger.js';
import myLogger from './logger.js'; // any name works
You can mix both:
// api.js
export default class APIClient { /* ... */ }
export const BASE_URL = 'https://api.example.com';
export function createHeaders() { /* ... */ }
// import both default and named
import APIClient, { BASE_URL, createHeaders } from './api.js';
Default exports make refactoring harder (the import name is arbitrary, so you can't find-and-replace across the codebase), provide worse IDE auto-import, and make tree shaking less predictable. Most style guides (including the one used at Google) prefer named exports. Use default exports only for the primary export of a module — like a React component in its own file.
Live Bindings — The Biggest Difference from CommonJS
This is the concept that trips up most developers coming from CommonJS. ES module exports are live bindings, not copies:
// counter.js
export let count = 0;
export function increment() {
count++;
}
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 — the value updated!
increment();
console.log(count); // 2 — still tracking the live value
Compare this to CommonJS, where you get a copy:
// counter.cjs
let count = 0;
function increment() { count++; }
module.exports = { count, increment };
// main.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // 0
increment();
console.log(count); // 0 — still 0! You got a copy of the number.
| Aspect | CommonJS | ES Modules |
|---|---|---|
| Binding type | Value copy at require() time | Live read-only binding to the original variable |
| Mutation visibility | Changes in exporter NOT seen by importer | Changes in exporter immediately visible to importer |
| Importer can modify? | Yes (it's just a local copy) | No — imported bindings are read-only (TypeError on assignment) |
| Reassignment | Importer has its own variable | Importer sees the exporter's current value |
Live bindings mean imported variables are read-only from the importer's perspective. You cannot reassign an imported binding — it throws a TypeError. But the exporting module can change the value, and every importer sees the update. This is fundamentally different from destructuring a CommonJS require, where you get your own copy to do whatever you want with.
// This throws a TypeError in ESM
import { count } from './counter.js';
count = 99; // TypeError: Assignment to constant variable.
// Even though count is declared with 'let' in counter.js,
// it's read-only from the importer's side.
Import Hoisting — Imports Run First, Always
All import declarations are hoisted to the top of the module and resolved before any module code executes:
// This works — even though the import is "after" the usage in source order
console.log(add(2, 3)); // 5
import { add } from './math.js';
// Imports are hoisted — they resolve before the first line of code runs
The engine processes your module in two phases: first it resolves all imports (building the dependency graph), then it executes the code. By the time console.log runs, add is already available.
Import hoisting fully initializes the binding — the imported value is available immediately. var hoisting only lifts the declaration (not the assignment), leaving you with undefined until the assignment runs. Import hoisting is closer to function declaration hoisting in that the binding is fully usable from the first line.
Strict Mode by Default
Every ES module runs in strict mode automatically. No "use strict" directive needed:
// In an ES module, all of these are errors:
x = 5; // ReferenceError: x is not defined
delete Object.prototype; // TypeError
function f(a, a) {} // SyntaxError: duplicate parameter
with (obj) {} // SyntaxError
arguments.callee; // TypeError in strict mode
// These also apply:
// - 'this' at module top level is undefined, not globalThis
// - Octal literals (0777) are syntax errors
// - Setting properties on primitives throws
This is a good thing. Strict mode catches bugs, prevents foot-guns, and enables engine optimizations. But it can break legacy code that relies on sloppy mode behavior — another reason the CJS-to-ESM migration can be tricky.
Top-Level await
ES modules support await at the top level — no need for an async wrapper function:
// config.js — top-level await
const response = await fetch('/api/config');
export const config = await response.json();
// Any module that imports config.js will wait for the fetch to complete
// before its own code runs
// db.js
const connection = await createConnection({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
});
export { connection };
Top-level await blocks the execution of the current module and any module that depends on it, but it doesn't block sibling modules that don't depend on the awaiting module. The engine is smart about this — it can continue loading independent branches of the module graph in parallel.
Top-level await is a powerful feature, but it delays your module graph initialization. If your top-level await takes 2 seconds (like a database connection), every module that transitively depends on it waits those 2 seconds before executing. Use it for things that genuinely need to be ready before any code runs — not as a convenience to avoid async patterns.
Dynamic import() — When Static Isn't Enough
Sometimes you need to load modules conditionally or lazily. That's what dynamic import() is for:
// Conditional loading
if (platform === 'node') {
const { readFile } = await import('node:fs/promises');
}
// Lazy loading — load only when needed
button.addEventListener('click', async () => {
const { openModal } = await import('./modal.js');
openModal();
});
// Computed specifiers — works with variables
const locale = getUserLocale();
const strings = await import(`./i18n/${locale}.js`);
import() returns a Promise that resolves to the module's namespace object. Unlike static import, it can be used anywhere — inside functions, conditionals, loops. It's the standard mechanism for code splitting in bundlers like Vite and webpack.
// The resolved object has all named exports as properties
// and the default export as .default
const module = await import('./math.js');
module.add(2, 3); // named export
module.default; // default export (if any)
import.meta — Module-Level Metadata
import.meta is an object that provides metadata about the current module:
// import.meta.url — the URL of the current module
console.log(import.meta.url);
// In Node.js: 'file:///Users/you/project/app.js'
// In browser: 'https://example.com/app.js'
// Getting __dirname equivalent in ESM
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Or using the newer Node.js API (v21.2+)
const __dirname = import.meta.dirname; // Added in Node.js 21.2
const __filename = import.meta.filename; // Added in Node.js 21.2
In Node.js, import.meta also provides import.meta.resolve() for resolving module specifiers to URLs:
// Resolve a module specifier without loading it
const resolvedPath = import.meta.resolve('./utils.js');
// 'file:///Users/you/project/utils.js'
const lodashPath = import.meta.resolve('lodash');
// 'file:///Users/you/project/node_modules/lodash/lodash.js'
import.meta is the ESM replacement for the CommonJS globals that don't exist in ES modules (__dirname, __filename, require.resolve). The object is extensible — runtimes can add their own properties. Vite adds import.meta.env for environment variables and import.meta.hot for HMR. Node.js added import.meta.dirname and import.meta.filename in v21.2 as direct replacements for the CommonJS equivalents, so you no longer need the fileURLToPath dance.
The ESM Loading Algorithm
ES modules load in three distinct phases — unlike CommonJS which does everything in a single synchronous pass:
This three-phase approach is why live bindings work — the binding connections are set up in phase 2, before any code runs in phase 3. It's also why circular dependencies in ESM behave differently from CommonJS.
- 1ES module imports are statically analyzed at parse time — you can't use import inside conditionals or with computed strings
- 2Exports are live bindings, not copies — importers always see the current value
- 3Imported bindings are read-only — only the exporting module can change the value
- 4All imports are hoisted and resolved before any module code executes
- 5ES modules are always in strict mode — no opt-out
- 6Use dynamic import() for conditional loading, lazy loading, and code splitting
- 7import.meta provides module metadata like URL, dirname (Node.js 21.2+), and resolve()