DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/JavaScript/Modules (ESM, dynamic import)
intermediate20 min read·Updated Jun 2025

Modules (ESM, dynamic import)

ES Modules aren't just 'import/export instead of require' — they're statically analyzable, live-bound, and strict by default. Those three properties are why bundlers can tree-shake ESM but not CommonJS, and why an imported binding updates automatically when its source module changes it.

modulesesmcommonjsimportexporttree-shaking

Knowledge Check

4 questions · pass at 70%

0/4
mediummcq

1. What makes ES Modules statically analyzable, unlike CommonJS?


Interview Questions

2 questions

Cheatsheet

Download-ready reference
Cheatsheet
CommonJS (require/module.exports):        ES Modules (import/export):
  runtime function call                     static, declarative syntax
  can be conditional/dynamic                 must be top-level
  require() returns a SNAPSHOT               import is a LIVE, read-only binding
  not statically analyzable                   fully analyzable → enables tree-shaking

Live binding example:
  // a.js: export let x = 1; export function set(v) { x = v; }
  // b.js: import { x, set } from './a.js';
  //       set(2); console.log(x); // 2 — automatically updated!

Dynamic import():
  const mod = await import('./module.js');  // FUNCTION call, returns a Promise
  → enables code-splitting: bundler creates a separate chunk, loaded on demand

Named export:   export const x = 1;      →  import { x } from '...'
Default export: export default function  →  import anyName from '...'  (only ONE per module)

Tree-shaking requires: static ESM syntax + accurate "sideEffects" metadata
  in dependencies — a single CJS-only dependency in the chain can block it.

Related topics

ScopeAsync/Await

On this page

20m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

The beginner framing: a module is a file whose variables are private to itself by default — nothing leaks into the global scope, and you explicitly choose what to share (export) and what to use from elsewhere (import).

The precise mental model: JavaScript has had two competing module systems that solve the same problem very differently. CommonJS (require/module.exports) is Node's original system — it's fundamentally a runtime mechanism: require() is just a function call that executes and returns an object, evaluated exactly when and where it's called. ES Modules (import/export, "ESM") are a language-level, static feature — every import/export must appear at the top level (never inside an if or function), which lets the engine (and bundlers) determine the entire dependency graph before running any code at all.

// CommonJS — require() is an ordinary function call, evaluated at runtime
const { add } = require("./math"); // could be inside an if-statement — perfectly legal
 
// ES Modules — import is a static DECLARATION, not a function call
import { add } from "./math.js"; // must be top-level; the string can't be a variable

This static-vs-runtime distinction is the root cause of nearly every practical difference between the two systems.

Tree-shaking only works because ESM is statically analyzable#

Because a bundler can see every import/export at build time without running any code, it can determine which exports are never actually used anywhere and delete them from the final bundle — this is tree-shaking. CommonJS's require() calls can be conditional, dynamic, or computed at runtime, so a bundler generally can't prove an export is unused — it has to assume everything might be needed and include it all.

Live bindings: the single biggest CJS/ESM behavioral difference#

// counter.js (ESM)
export let count = 0;
export function increment() { count += 1; }
// main.js (ESM)
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1 — the imported binding AUTOMATICALLY reflects the change!

An ESM import isn't a copy of a value — it's a live, read-only view into the exporting module's actual binding. When counter.js changes count, every module that imported it sees the new value immediately, with no re-import needed. CommonJS's require() returns a plain object — a snapshot at the moment of the call. If the source module later reassigns an exported variable (rather than mutating an exported object's properties), CommonJS importers do NOT see the change.

Dynamic import()#

button.addEventListener("click", async () => {
  const { default: Chart } = await import("./chart-library.js"); // loaded ONLY when needed
  Chart.render(data);
});

Unlike static import, the import() function can be called anywhere (conditionally, inside a function) and returns a promise that resolves to the module's exports — this is the foundation of code-splitting: bundlers see a dynamic import() call and automatically split that module into a separate chunk, loaded on demand instead of upfront.

Named vs. default exports#

export const PI = 3.14159;       // named export — imported with matching name: import { PI }
export default function main() {} // default export — ONE per module, imported with any name
import main, { PI } from "./utils.js"; // combining both in one import statement

Try It#

Predict the output of main.js before checking the solution.

// state.js
export let value = "initial";
export function setValue(v) { value = v; }
// main.js
import { value, setValue } from "./state.js";
 
console.log(value);
setValue("updated");
console.log(value);
Solution
initial
updated

This only works because ESM imports are live bindings, not copied values. If this were CommonJS (const { value } = require("./state")), the second console.log would still print "initial" — the destructured value would be a snapshot taken at require-time, completely disconnected from any later change to the source module's internal variable.


Implement It Yourself#

Implement a minimal CommonJS-style module loader to see exactly what require()/module.exports actually do under the hood:

const moduleCache = {};
 
function myRequire(id, moduleFactories) {
  if (moduleCache[id]) return moduleCache[id].exports; // cached — same object every time
 
  const module = { exports: {} };
  moduleCache[id] = module; // cache BEFORE running, to handle circular requires safely
 
  const factory = moduleFactories[id];
  factory(module, module














This mirrors, at a simplified level, exactly what Node.js and bundlers like Webpack do to implement require(): wrap each module's code in a function, run it once, cache the resulting exports object, and return that same cached object for every subsequent require() of the same module — which is also why CommonJS modules are singletons (running side effects only once, no matter how many times they're required).


In React#

Every import Component from "./Component" in a React codebase is ESM, and understanding static analysis explains two things you rely on constantly without thinking about them: tree-shaking (importing one icon from an icon library doesn't bundle the entire library, as long as the library ships proper ESM with side-effect-free exports) and why React.lazy() takes a function that calls import() rather than a static import — React.lazy(() => import("./HeavyComponent")) is exactly the dynamic-import code-splitting pattern from this topic, letting the bundler split HeavyComponent into its own chunk, loaded only when it's actually rendered.


Common Mistakes#

1. Mixing require and import in the same project without understanding interop#

// utils.js (CommonJS)
module.exports = { add: (a, b) => a + b };
// main.mjs (ESM) — importing a CJS module
import utils from "./utils.js"; // works, but utils is the WHOLE module.exports object
import { add } from "./utils.js"; // often does NOT work reliably — CJS has no static exports to analyze

Bundlers and Node do their best to interoperate CJS and ESM, but named imports from a CommonJS module are unreliable (the tool has to guess the shape via runtime inspection, since CJS offers no static export list). Prefer importing the default and destructuring afterward, or migrate the CJS module to ESM.

2. Assuming a reassigned export updates like a live binding — in CommonJS#

// counter.js (CommonJS)
let count = 0;
module.exports.count = count;
module.exports.increment = () => { count += 1; }; // mutates the LOCAL `count`, not module.exports.count
const { count, increment } = require("./counter");
increment();
console.log(count); // STILL 0 — the exported `count` was a one-time copy, never updated

This is the CommonJS mirror image of the ESM live-binding example — a genuinely common source of confusion for developers moving between the two systems.

3. Forgetting that circular imports resolve to partial/incomplete modules#

// a.js
import { b } from "./b.js";
export const a = "a-value";
console.log(b); // may be undefined — b.js hasn't finished running yet if it imports a.js too

Both CommonJS and ESM handle circular dependencies by returning whatever has been exported so far rather than deadlocking — but that means the imported value may be incomplete or undefined depending on execution order. Circular dependencies are a design smell worth refactoring away from, in either module system.


Best Practices#

  • Prefer ESM for new code — static analyzability enables tree-shaking, and live bindings avoid an entire class of stale-data bugs.
  • Use dynamic import() for genuinely optional or heavy code (rarely-used features, large libraries only needed after user interaction) to enable code-splitting.
  • Avoid circular dependencies — if module A needs something from B and B needs something from A, it's usually a sign that shared logic should be extracted into a third module both depend on.
  • Be consistent about default vs. named exports within a project — mixing conventions arbitrarily makes imports harder to predict.

Performance Tips#

  • Tree-shaking only works if your dependencies actually ship ESM with "sideEffects": false (or accurately marked side effects) in their package.json — a library that ships only CommonJS, or claims side effects it doesn't have, defeats tree-shaking even if your OWN code is perfect ESM.
  • Dynamic import() for code-splitting is one of the highest-leverage bundle-size optimizations available — deferring rarely-used, heavy code (a rich text editor, a charting library, an admin-only panel) out of the initial bundle can meaningfully improve first-load performance.
  • Avoid re-exporting everything through a single "barrel" index.js (export * from "./a"; export * from "./b"; ...) in large codebases — depending on the bundler, this can sometimes defeat tree-shaking by forcing the whole barrel module to be evaluated even when only one deep export is used.

.
exports
, (
dep
)
=>
myRequire
(dep, moduleFactories));
// run the module body
return module.exports;
}
const modules = {
math: (module) => {
module.exports.add = (a, b) => a + b;
},
main: (module, exports, require) => {
const math = require("math");
console.log(math.add(2, 3)); // 5
},
};
myRequire("main", modules);