Learn ES Modules in JavaScript. Understand import/export, live bindings, dynamic imports, top-level await, and tree-shaking.
Why does Node.js have two different module systems? Why can bundlers remove unused code from ES Modules but not from CommonJS? And why do some imports need curly braces while others don’t?ES Modules (ESM) is JavaScript’s official module system, standardized in ES2015. It’s the answer to years of competing module formats, and it’s designed from the ground up to be statically analyzable, which unlocks optimizations that older systems simply can’t match.
Copy
Ask AI
// math.js - Exporting functionalityexport const PI = 3.14159export function square(x) { return x * x}// app.js - Importing what you needimport { PI, square } from './math.js'console.log(square(4)) // 16console.log(PI) // 3.14159
This guide goes beyond the basics. You’ll learn why ESM’s design makes it better than CommonJS for tooling and optimization, how live bindings work, and the practical differences between browsers and Node.js.
What you’ll learn in this guide:
Why ES Modules exist and what problems they solve
The key differences between ESM and CommonJS (and when each applies)
How live bindings make ESM exports work differently than CommonJS
All the export and import syntax variations
Dynamic imports for code splitting and lazy loading
Top-level await and when to use it
Browser vs Node.js: how ESM works in each environment
Import maps for bare module specifiers in browsers
How ESM enables tree-shaking and smaller bundles
Prerequisite: This guide assumes you’re familiar with basic module concepts. If terms like “named exports” or “default exports” are new to you, start with our IIFE, Modules & Namespaces guide first.
For most of JavaScript’s history, there was no built-in way to split code into reusable pieces. The language simply didn’t have modules. Developers created workarounds: IIFEs to avoid polluting the global scope, the Module Pattern for encapsulation, and eventually third-party systems like CommonJS (for Node.js) and AMD (for browsers).These solutions worked, but they were all invented outside the language itself. Each had tradeoffs, and none could be fully optimized by JavaScript engines or build tools.ES Modules changed that. Introduced in ES2015 (ES6) and formally defined in the ECMAScript Language Specification, ESM is part of the language itself. This means:
Browsers can load modules natively without bundlers (though bundlers still help with optimization)
Tools can analyze your code statically because imports and exports are declarative
Unused code can be eliminated (tree-shaking) because the module graph is known at build time
The syntax is standardized across all JavaScript environments
Today, ESM is supported in all modern browsers and Node.js — according to Can I Use, ES Modules have over 95% global browser support. It’s the module system you should use for new projects.
Think of ES Modules like the standardized shipping container that revolutionized global trade.Before shipping containers, cargo was loaded piece by piece. Every ship, truck, and warehouse had different ways of handling goods. It was slow, error-prone, and impossible to optimize at scale.Shipping containers changed everything. A standard size meant cranes, ships, and trucks could all handle cargo the same way. You could plan logistics before the ship even arrived because you knew exactly what you were dealing with.
Copy
Ask AI
┌─────────────────────────────────────────────────────────────────────────┐│ COMMONJS vs ES MODULES │├─────────────────────────────────────────────────────────────────────────┤│ ││ COMMONJS (Dynamic Loading) ES MODULES (Static Analysis) ││ ─────────────────────────── ──────────────────────────── ││ ││ ┌──────────────────────┐ ┌──────────────────────┐ ││ │ require('./math') │ │ import { add } │ ││ │ │ │ from './math.js' │ ││ │ Resolved at │ │ │ ││ │ RUNTIME │ │ Known at │ ││ │ │ │ BUILD TIME │ ││ │ Could be anything: │ │ │ ││ │ require(userInput) │ │ Tools can: │ ││ │ require(condition │ │ • See all imports │ ││ │ ? 'a' : 'b') │ │ • Remove dead code │ ││ │ │ │ • Optimize bundles │ ││ └──────────────────────┘ └──────────────────────┘ ││ ││ Like loose cargo: Like shipping containers: ││ flexible but hard standardized and ││ to optimize optimizable ││ │└─────────────────────────────────────────────────────────────────────────┘
ESM’s static structure is like those shipping containers. Because imports and exports are declarative (not computed at runtime), tools can “see” your entire module graph before running any code. This visibility enables optimizations that are simply impossible with dynamic systems like CommonJS.
If you’ve worked with Node.js, you’ve used CommonJS. It’s been Node’s module system since the beginning. But ESM and CommonJS work differently at a core level.
CommonJS imports are function calls that happen at runtime. You can put them anywhere, compute the path dynamically, and even conditionally require different modules:
ESM imports must be at the top level with string literals. This seems restrictive, but it’s a feature, not a bug:
Copy
Ask AI
// ES Modules - Static (enables optimization)import { feature } from './heavy-feature.js' // must be top-levelimport { helper } from './utils.js' // path must be a string// ❌ These are syntax errors in ESM:// import { x } from condition ? 'a.js' : 'b.js'// if (condition) { import { y } from './module.js' }
Because ESM imports are static, bundlers can build a complete picture of your dependencies before running any code. This enables dead code elimination, bundle splitting, and other optimizations.
Need dynamic loading in ESM? Use import() for dynamic imports (covered later in this guide). You get the best of both worlds: static analysis for your main code, dynamic loading when you actually need it.
CommonJS loads modules synchronously. When Node.js hits a require(), it blocks until the file is read and executed. This works fine on a server with fast disk access.ESM loads modules asynchronously. The browser fetches module files over the network, which can’t block the main thread. This async nature is why:
Here’s a difference that trips people up. As MDN documents, when you import from a CommonJS module, you get a copy of the exported value. When you import from an ES Module, you get a live binding: a reference to the original variable.
Singleton state works correctly — If a module exports state, all importers see the same state
Circular dependencies are safer — Because bindings are live, you can have modules that depend on each other (though you should still avoid this when possible)
You can’t reassign imports — count = 5 throws an error because you don’t own that binding
Copy
Ask AI
import { count } from './counter.js'count = 10 // ❌ TypeError: imported bindings are read-only // Even though 'count' is 'let' in the source, you can't reassign it here
Imported bindings are always read-only to the importer. Only the module that exports a variable can modify it. This prevents confusing “action at a distance” bugs.
The most common pattern. You can export inline or group exports at the bottom:
Copy
Ask AI
// Inline named exportsexport const PI = 3.14159export function calculateArea(radius) { return PI * radius * radius}export class Circle { constructor(radius) { this.radius = radius }}// Or group them at the bottom (same result)const PI = 3.14159function calculateArea(radius) { return PI * radius * radius}class Circle { constructor(radius) { this.radius = radius }}export { PI, calculateArea, Circle }
Each module can have one default export. It represents the module’s “main” thing:
Copy
Ask AI
// A class as default exportexport default class Logger { log(message) { console.log(`[LOG] ${message}`) }}// Or a functionexport default function formatDate(date) { return date.toISOString()}// Or a value (note: no variable declaration with default)export default { name: 'Config', version: '1.0.0'}
// React does this: default for the main API, named for utilitiesexport default function React() { /* ... */ }export function useState() { /* ... */ }export function useEffect() { /* ... */ }// Consumer can import both:import React, { useState, useEffect } from 'react'
Re-exports let you aggregate multiple modules into one entry point. This is common in libraries:
Copy
Ask AI
// utils/index.js (barrel file)export { formatDate, parseDate } from './date.js'export { formatCurrency } from './currency.js'export { default as Logger } from './logger.js'// Re-export everything from a moduleexport * from './math.js'// Re-export with renameexport { helper as utilHelper } from './helpers.js'
Now consumers can import from one place:
Copy
Ask AI
import { formatDate, formatCurrency, Logger } from './utils/index.js'
Barrel file gotcha: Re-exporting everything with export * can hurt tree-shaking. The bundler may include code you don’t use. Prefer explicit re-exports for better optimization.
// The module exports: export default class Logger { }import Logger from './logger.js' // common convention: match the exportimport MyLogger from './logger.js' // but any name worksimport L from './logger.js' // even short names
import * as math from './math.js'console.log(math.PI) // 3.14159console.log(math.calculateArea(5)) // 78.54console.log(math.default) // the default export, if any
The string after from is called the module specifier:
Copy
Ask AI
// Relative paths (start with ./ or ../)import { x } from './utils.js'import { y } from '../shared/helpers.js'// Absolute paths (less common)import { z } from '/lib/utils.js'// Bare specifiers (no path prefix)import { useState } from 'react' // needs bundler or import mapimport lodash from 'lodash'
Bare specifiers like 'react' don’t work in browsers by default — browsers don’t know where to find 'react'. You need either a bundler or an import map (covered later).
Every ES Module runs in strict mode automatically. No "use strict" needed:
Copy
Ask AI
// In a module, this throws an error:undeclaredVariable = 'oops' // ReferenceError: undeclaredVariable is not defined// These also fail:delete Object.prototype // TypeErrorfunction f(a, a) {} // SyntaxError: duplicate parameter
Variables in a module are local to that module, not global:
Copy
Ask AI
// module.jsconst privateValue = 'secret' // not on window/globalvar alsoPrivate = 'hidden' // var doesn't leak to global either// Only exports are accessible from outsideexport const publicValue = 'visible'
import() looks like a function call, but it’s special syntax. It returns a Promise that resolves to the module’s namespace object:
Copy
Ask AI
// Load a module dynamicallyconst module = await import('./math.js')console.log(module.PI) // 3.14159console.log(module.default) // the default export, if any// Or with .then()import('./math.js').then(module => { console.log(module.PI)})
// Only load heavy charting library if user needs itasync function showChart(data) { const { Chart } = await import('chart.js') const chart = new Chart(canvas, { /* ... */ })}
Lazy loading based on feature detection:
Copy
Ask AI
let cryptoif (typeof window !== 'undefined' && window.crypto) { crypto = window.crypto} else { // Only load polyfill in environments that need it const module = await import('crypto-polyfill') crypto = module.default}
Loading based on user preference:
Copy
Ask AI
async function loadTheme(themeName) { // Path is computed at runtime - not possible with static imports const theme = await import(`./themes/${themeName}.js`) applyTheme(theme.default)}
import() works in regular scripts too, not just modules. This is useful for adding ESM libraries to legacy codebases.
When a module uses top-level await, it blocks modules that depend on it:
Copy
Ask AI
// slow.jsawait new Promise(r => setTimeout(r, 2000)) // 2 second delayexport const value = 42// app.jsimport { value } from './slow.js' // waits for slow.js to finishconsole.log(value) // logs after 2 seconds
Modules that don’t depend on slow.js can still load in parallel.
// Loading configuration at startupexport const config = await loadConfig()// Database connection that's needed before anything elseexport const db = await connectToDatabase()// One-time initializationawait initializeAnalytics()
Avoid:
Copy
Ask AI
// ❌ Don't do slow operations that could be lazyconst heavyData = await fetch('/api/huge-dataset') // blocks everything// ✓ Better: export a function that fetches when neededexport async function getHeavyData() { return fetch('/api/huge-dataset')}
Top-level await can create waterfall loading. If module A awaits and module B depends on A, then module C depends on B, everything loads sequentially. Use it judiciously.
Use .mjs extension, or set "type": "module" in package.json
Browser:
Copy
Ask AI
<!-- The type="module" attribute enables ESM --><script type="module" src="./app.js"></script><!-- Inline module --><script type="module"> import { greet } from './utils.js' greet('World')</script>
Node.js:
Copy
Ask AI
// Option 1: Use .mjs extension// math.mjsexport const add = (a, b) => a + b// Option 2: Set type in package.json// package.json: { "type": "module" }// Then .js files are treated as ESM
When loading modules from different origins, browsers enforce CORS:
Copy
Ask AI
<!-- Same-origin: works fine --><script type="module" src="/js/app.js"></script><!-- Cross-origin: server must send CORS headers --><script type="module" src="https://other-site.com/module.js"></script><!-- Requires: Access-Control-Allow-Origin header -->
Import maps are supported in all modern browsers (Chrome 89+, Safari 16.4+, Firefox 108+). For older browsers, you’ll need a polyfill or bundler.
Import maps are great for simple projects, demos, and learning. For production apps with many dependencies, bundlers like Vite still provide better optimization and developer experience.
Tree-shaking removes unused exports from your final bundle:
Copy
Ask AI
// math.jsexport function add(a, b) { return a + b }export function subtract(a, b) { return a - b }export function multiply(a, b) { return a * b }export function divide(a, b) { return a / b }// app.jsimport { add } from './math.js'console.log(add(2, 3))
A tree-shaking bundler sees that only add is used, so subtract, multiply, and divide are removed from the bundle.
Even with native ESM support in browsers, bundlers remain valuable for:
Tree-shaking — Remove unused code
Code splitting — Break your app into smaller chunks
Minification — Shrink code for production
Transpilation — Support older browsers
Asset handling — Import CSS, images, JSON
Popular options:
Vite — Fast development, Rollup-based production builds
esbuild — Extremely fast, great for libraries
Rollup — Best tree-shaking, ideal for libraries
Webpack — Most features, larger projects
For small projects or learning, you can use native ESM in browsers without a bundler. For production apps, bundlers still provide significant benefits.
This is the most common ESM mistake. The syntax looks similar but means different things:
Copy
Ask AI
// ─────────────────────────────────────────────// The module exports this:export default function Logger() {}export function format() {}// ─────────────────────────────────────────────// ❌ WRONG - trying to import default as namedimport { Logger } from './logger.js'// Error: The module doesn't have a named export called 'Logger'// ✓ CORRECT - no braces for defaultimport Logger from './logger.js'// ✓ CORRECT - braces for named exportsimport { format } from './logger.js'// ✓ CORRECT - both togetherimport Logger, { format } from './logger.js'
Copy
Ask AI
┌─────────────────────────────────────────────────────────────────────────┐│ THE CURLY BRACE RULE │├─────────────────────────────────────────────────────────────────────────┤│ ││ export default X → import X from '...' (no braces) ││ export { Y } → import { Y } from '...' (braces) ││ export { Z as W } → import { W } from '...' (braces) ││ ││ Default = main thing, you name it ││ Named = specific items, names must match ││ │└─────────────────────────────────────────────────────────────────────────┘
When module A imports module B, and module B imports module A, you can get undefined values:
Copy
Ask AI
// a.jsimport { b } from './b.js'export const a = 'A'console.log('In a.js, b is:', b)// b.jsimport { a } from './a.js'export const b = 'B'console.log('In b.js, a is:', a)// Running a.js throws:// ReferenceError: Cannot access 'a' before initialization// (a.js hasn't finished executing when b.js tries to access 'a')
Fix: Restructure to avoid circular deps, or use functions that defer access until runtime:
Copy
Ask AI
// Better: export functions that read values at call timeexport function getA() { return a }
You can’t use require() in an ESM file or import in a CommonJS file without extra steps:
Copy
Ask AI
// ❌ In an ESM file (.mjs or type: module)const fs = require('fs') // ReferenceError: require is not defined// ✓ CORRECT in ESMimport fs from 'fs'import { readFile } from 'fs/promises'// ✓ If you really need require in ESMimport { createRequire } from 'module'const require = createRequire(import.meta.url)const legacyModule = require('some-commonjs-package')
ESM is JavaScript’s official module system — It’s standardized, works in browsers natively, and is the future of JavaScript modules.
Static structure enables optimization — Because imports are declarations, not function calls, tools can analyze your code and remove unused exports (tree-shaking).
Live bindings, not copies — ESM exports are references to the original variable. Changes in the source module are reflected in importers. CommonJS exports are value copies.
Use curly braces for named imports, no braces for default — import { named } vs import defaultExport. Mixing these up is the #1 beginner mistake.
Dynamic imports for code splitting — Use import() when you need to load modules conditionally or lazily. It returns a Promise.
ESM is always strict mode — No need for "use strict". Variables don’t leak to global scope.
Modules execute once — No matter how many files import a module, its top-level code runs exactly once. Modules are singletons.
File extensions are required — In browsers and Node.js ESM, you must include .js. No automatic extension resolution.
Import maps solve bare specifiers in browsers — Without a bundler, use import maps to tell browsers where to find packages like 'lodash'.
Bundlers still matter — Even with native ESM support, bundlers provide tree-shaking, minification, and code splitting that improve production performance.
What's the fundamental difference between ESM and CommonJS that enables tree-shaking?
Answer:ESM imports are static — they must be at the top level with string literals. This means bundlers can analyze the entire dependency graph at build time without running any code.CommonJS uses dynamicrequire() calls that execute at runtime. The module path can be computed (require(variable)), used conditionally, or placed anywhere in code. Bundlers can’t know what’s actually imported until the code runs.
Copy
Ask AI
// ESM - bundler sees exactly what's importedimport { add } from './math.js' // static, analyzable// CommonJS - bundler can't be certainconst op = condition ? 'add' : 'subtract'const math = require('./math')math[op](1, 2) // which function is used? Unknown until runtime
What are 'live bindings' and how do they differ from CommonJS exports?
Answer:In ESM, imported bindings are live references to the exported variables. If the source module changes the value, importers see the new value.In CommonJS, module.exports provides value copies at the time of require(). Later changes in the source don’t affect what was imported.
Static imports are better when you always need the module — they’re faster to analyze and optimize.
Why do browsers require file extensions in imports, but Node.js CommonJS doesn't?
Answer:Browsers make HTTP requests for imports. Without an extension, the browser doesn’t know what URL to request. It can’t try multiple extensions (.js, .mjs, /index.js) because each would be a separate network request.Node.js CommonJS runs on the local file system where checking multiple file variations is fast. It tries: exact path → .js → .json → .node → /index.js, etc.Node.js ESM chose to require extensions for consistency with browsers and to avoid the ambiguity of the CommonJS resolution algorithm.
Copy
Ask AI
// Browser - must include extensionimport { x } from './utils.js' // ✓import { x } from './utils' // ❌ 404// Node CommonJS - extension optionalconst x = require('./utils') // ✓ finds utils.js// Node ESM - extension requiredimport { x } from './utils.js' // ✓import { x } from './utils' // ❌ ERR_MODULE_NOT_FOUND
What is an import map and when would you use one?
Answer:An import map is a JSON object that tells browsers how to resolve bare module specifiers (like 'lodash'). It maps package names to URLs.
Copy
Ask AI
<script type="importmap">{ "imports": { "lodash": "https://cdn.jsdelivr.net/npm/lodash-es/lodash.js", "lodash/": "https://cdn.jsdelivr.net/npm/lodash-es/" }}</script><script type="module"> import { debounce } from 'lodash' // works now!</script>
Use import maps when:
Building simple apps without a bundler
Creating demos or examples
Learning/prototyping
You want CDN-based dependencies
For production apps with many dependencies, bundlers usually provide better optimization.
What happens if two modules import each other (circular dependency)?
Answer:ESM handles circular dependencies, but you can get errors for values that haven’t been initialized yet.
Copy
Ask AI
// a.jsimport { b } from './b.js'export const a = 'A'console.log(b) // 'B' (b.js already ran)// b.jsimport { a } from './a.js'export const b = 'B'console.log(a) // ReferenceError! (a.js hasn't finished)
When b.js runs, a.js is still in the middle of executing (it imported b.js), so accessing a throws a ReferenceError: Cannot access 'a' before initialization because const declarations have a temporal dead zone (TDZ).Solutions:
Restructure to avoid circular dependencies
Move shared code to a third module
Use functions that access values later (not at module load time)
Copy
Ask AI
// Works: function accesses 'a' when called, not when definedexport function getA() { return a }
ES Modules (ESM) are JavaScript’s official module system, standardized in ES2015. They use import and export statements to share code between files. Unlike older module systems like CommonJS, ESM is statically analyzable, meaning tools can determine your dependency graph at build time rather than runtime.
What is the difference between ES Modules and CommonJS?
The key differences are: ESM uses import/export while CommonJS uses require()/module.exports; ESM loads asynchronously while CommonJS is synchronous; ESM provides live bindings (references) while CommonJS creates value copies. According to the Node.js documentation, ESM is the standard for new projects, though CommonJS remains widely used in existing codebases.
How does tree-shaking work with ES Modules?
Tree-shaking removes unused code from your final bundle. It works because ESM imports and exports are static declarations — bundlers like webpack and Rollup can analyze which exports are actually used and eliminate the rest. This optimization is impossible with CommonJS because require() calls can be dynamic and conditional.
What are live bindings in ES Modules?
Live bindings mean that when you import a value from an ES Module, you get a read-only reference to the original variable, not a copy. If the exporting module changes that variable, the importing module sees the updated value. MDN documents this as one of the key distinctions between ESM and CommonJS.
How do dynamic imports work in JavaScript?
Dynamic imports use the import() function, which returns a Promise that resolves to the module’s namespace object. Unlike static import declarations, import() can be called anywhere in your code — inside conditionals, loops, or event handlers. This enables code splitting and lazy loading of modules on demand.
Do I need a bundler to use ES Modules?
No. All modern browsers support ES Modules natively via <script type="module">. However, bundlers like webpack, Rollup, and Vite still provide benefits for production: tree-shaking, code splitting, minification, and better caching strategies. For small projects or prototypes, native browser ESM works well without a bundler.