Modules
What ES modules solve
Before modules, a script's top-level variables belonged to everyone. Sharing code meant globals and hoping the load order held. A module fixes both problems at once. Its top-level declarations stay private unless exported (the same closure mechanics you already know, applied at file granularity), and import states exactly what the file needs and from where. The runtime can build the whole dependency graph by reading the import lines, without executing anything. Keep that last property in mind; most of what follows depends on it.
Import and export syntax rules
// counter.js
export let count = 0; // named export
export function increment() { count++; }
export default class Counter {} // at most one per module
// main.js
import Counter, { count, increment } from "./counter.js";
import * as counter from "./counter.js"; // namespace object
The syntax enforces four rules:
- Static only.
import/exportlive at the top level, and the specifier is a string literal. Noimportinsideif, no computed paths. ❌ "I'll just import conditionally": the static form can't, and that restriction is what makes the graph analyzable before execution (it is also the foundation tree shaking stands on). The escape hatch is the function form,import("./thing.js"), which returns a promise and is allowed anywhere. - Hoisted. Imports are processed before the module body runs, so you can call an imported function on line 1 even if the
importsits on line 20. Corollary: moving an import line down does not delay it. - Always strict. Modules run in strict mode without asking; top-level
thisisundefined, notwindow. - Evaluated once per graph. Ten files import
config.js; its body runs once, and everyone shares the same instance. ❌ "Each import re-runs the module": it doesn't, and this is why a module-levelconst cache = new Map()works as an app-wide singleton.
In the browser, <script type="module"> also loads differently (deferred by default); the mechanics live in critical-rendering-path.
Named exports vs default exports
A default export is a real thing with real consequences (the interop story is the middle level), but at this depth the practical difference is naming. Named exports are checked names: a typo'd import { incrment } fails immediately, refactor-rename tools follow them, editors auto-import them. A default export is nameless. Every importer invents a local name, and import Button from and import Btn from are both "correct".
A defensible team convention is named exports everywhere, with default only where a framework demands it (route files, React.lazy targets). If your codebase has three names for the same component, this is why.
What CommonJS is
Node's older system, require("./x") and module.exports, is an ordinary function call: it runs at execution time, can sit inside if, and hands back a plain object. Millions of npm packages still ship it, so the two systems have to coexist. How they coexist (copies vs live bindings, interop rules, what happens in a cycle) is covered at the middle level.
Live bindings vs copies
The most consequential difference between the two systems fits in one demo:
// counter.js / counter.cjs
export let count = 0; // ESM
export function increment() { count++; }
// CJS: exports.count = 0; exports.increment = () => { exports.count++; };
// ESM consumer // CJS consumer
import { count, increment } from "./counter.js";
increment(); const { count, increment } = require("./counter.cjs");
console.log(count); // 1 increment();
console.log(count); // 0
An ESM import is a live binding: a read-only view into the exporting module's actual variable. Nothing is copied; when counter.js reassigns count, every importer sees it. ❌ "import is like destructuring": the CJS const { count } = require(...) is the destructuring here, and it copies the number at require time and goes stale. Read-only cuts one way. The importer can't assign (count = 5 in main.js is a TypeError); the exporting module reassigns freely.
CJS hands you one plain object, module.exports, at call time. Keep the object (counter.count) and you stay current; destructure and you snapshot. Two more CJS traps: exports = {...} rebinds the local alias and exports nothing (assign to module.exports to replace wholesale), and the require cache is keyed by resolved filename, which is how one module stays a singleton.
How ESM and CommonJS interop works
ESM importing CJS works, with synthesis. The default import is module.exports itself; Node additionally analyzes the CJS source statically (cjs-module-lexer) to synthesize named exports, so import { readFileSync } from "node:fs" works. Dynamic export patterns defeat the analysis, and then only the default import is reliable.
CJS importing ESM has two paths. import() always works; plain require(esm) works in current Node unless the ESM graph uses top-level await (ERR_REQUIRE_ASYNC_MODULE). Older Node forbade it entirely, hence a decade of dual packages (senior level).
The __esModule mess. When Babel/TypeScript compile ESM to CJS, they mark the output with __esModule: true so that import x from "./compiled" yields the original default export rather than the whole exports object. Tooling that agrees on this marker interops cleanly; mixing tools that don't is where obj.default.default and "component renders as undefined" bugs come from. ❌ "default exports are free": across the interop boundary they are the expensive choice, since named exports carry no such ambiguity.
How circular dependencies behave
❌ "Circular imports crash": neither system bans cycles; they degrade in different ways.
CJS: partial exports. When b.js requires a.js while a.js is mid-execution, Node returns a's exports object as it currently stands, meaning whatever was assigned above the require('./b') line and nothing below it. No error at the boundary; you get undefined properties and a failure later, often far from the cause. Statement order inside the modules decides what survives.
ESM: linked scopes + TDZ. The graph is linked before evaluation, so b's imports are real bindings into a's scope even mid-cycle. Hoisted function declarations are already initialized, so calling one across a cycle works. const/let/class bindings exist but stay uninitialized until their declaration runs; reading one throws ReferenceError: Cannot access 'x' before initialization. The failure is deterministic and immediate, which makes it easier to debug than CJS's silent partial object.
Either way a cycle is a design smell. Fixes: extract the shared piece into a third module both import; invert the dependency (pass a callback or instance instead of importing back); defer the access into a function that runs after evaluation; or split at an async boundary with import(). Lint for cycles (import/no-cycle) instead of debugging them.
The package.json exports map
{
"type": "module",
"exports": {
".": { "import": "./dist/index.js", "require": "./dist/index.cjs" },
"./client": { "browser": "./dist/client.browser.js", "default": "./dist/client.js" },
"./styles.css": "./dist/styles.css"
},
"sideEffects": ["./dist/styles.css"]
}
"exports" does two jobs. Encapsulation: subpaths not listed become unreachable, so adding the field to an existing package is a breaking change, and users deep-importing pkg/lib/utils.js get ERR_PACKAGE_PATH_NOT_EXPORTED. Conditional resolution: conditions (import, require, node, browser, development/production) are matched top-down, first hit wins, default last.
The import/require split above hides the dual-package hazard. Ship two builds and one process can load both: your ESM entry imports the package while some transitive CJS dep requires it. Two evaluations means two copies of module state. Singletons duplicate, instanceof fails across the boundary, and you get "the plugin registered but the host can't see it". Mitigations, in order of preference: ship one format (ESM-only if your consumers allow; the ecosystem is mostly there); or one real implementation with a thin wrapper for the other format, so state lives in exactly one evaluated copy; or keep dual builds strictly stateless. ❌ "we ship both, best of both worlds": without one of these mitigations you shipped a state-duplication bug with a delay timer.
What makes code tree-shakeable
Tree shaking is static reachability analysis over ESM's fixed structure, which is why every precondition amounts to keeping things static:
- ESM end to end. A CJS module (or ESM transpiled to CJS by a stray Babel/TS config; check
modules: false/"module": "esnext") is an opaque object built at runtime, so the bundler includes it whole. One CJS dependency makes its whole subtree unshakeable. - Side-effect honesty. Bundlers must assume a module's top level does something (polyfill, global patch, CSS import) and keep it even if nothing is imported from it.
"sideEffects": falseis your promise that dropping never-used modules is safe, and files that do have effects must be listed or they vanish silently from production. That drop happens at module granularity, before per-export analysis, and is the stronger of the two mechanisms. - Pure annotations. Within a kept module,
const Button = /*#__PURE__*/ styled("button")(...)tells the minifier the call may go ifButtonis unused. Without the comment, a top-level call is a potential side effect and stays. This is how component libraries get per-export elimination to work.
What barrel files cost
An index.ts re-exporting a directory is real DX: one import path, a curated surface. The costs are real too. Every consumer of the barrel parses the entire directory graph (build time, dev server cold start). With wrong or missing sideEffects metadata, importing one icon keeps ten thousand. export * from packages merges namespaces, so tools give up sooner. And barrels are where accidental cycles breed, because everything plausibly imports everything. A reasonable position: barrels at package boundaries with correct sideEffects and pure ESM behind them, never as an internal habit inside an app. Budgets and route boundaries, meaning whether and where to split what the bundle graph produces, belong to loading-performance.
Dynamic import() and code splitting
import() is the one sanctioned hole in the static structure: a call, legal anywhere, resolving to the namespace object as a promise; evaluation still happens once per module. Bundlers turn each call into a chunk boundary, and that boundary is the entire mechanism behind code splitting. The costs: the result is async (an await point and a loading state where a plain import was synchronous), a network round-trip at the moment of need, and static analysis stops at the boundary. A fully dynamic specifier (import(path)) is invisible to the graph, so bundlers either fail it or crudely glob a directory. Split where the loading strategy says to split (see loading-performance); everywhere else keep the static form, so the graph stays analyzable.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.