ES modules (also called ECMAScript modules or ESM) are the standard module system of the JavaScript language, defined in the ECMAScript specification and loaded natively by browsers through script type="module". In Browser JavaScript, an ES module is a file with its own top-level scope that exposes values with export and consumes values from other files with import, so that code on the Web can be split into reusable units without global variables.
The design is static: imports and exports are declared at the top level and can be analyzed before any code runs. This property allows the browser to fetch the whole dependency graph in parallel, lets build tools remove unused code, and distinguishes ES modules from older patterns such as CommonJS and script-tag globals.
How the browser loads an ES module graph
The browser processes ES modules in three distinct phases: construction, instantiation and evaluation. Separating these phases is what allows parallel fetching and live bindings, two features that classic scripts cannot offer.
During construction, the loader downloads the entry file, parses it without executing it, reads its import statements and fetches every referenced file recursively. Each URL is fetched only once per document, and the result is stored in a module map keyed by the resolved URL. During instantiation, the engine allocates memory for every exported binding and connects each import to the matching export. During evaluation, the module bodies run in depth-first, post-order sequence, so dependencies execute before the modules that import them.
The following table summarizes what happens in each phase and which errors typically appear there.
| Phase | What happens | Typical failure |
|---|---|---|
| Construction | Fetch, parse and discover imports | Network error, wrong MIME type, syntax error |
| Instantiation | Link imports to exports in memory | Missing export name, unresolved specifier |
| Evaluation | Run module code once, dependencies first | Runtime exception, access before initialization |
Because the bindings are live references rather than copies, a module that imports a counter sees its updated value when the exporting module changes it. Imported bindings are read-only for the importer, which prevents accidental cross-module mutation.
Syntax and behavior of import and export
ES modules offer named exports, a single default export per module, and several import forms that suit different needs. The choice affects readability, refactoring and how well tools can eliminate unused code.
| Form | Example | Typical use |
|---|---|---|
| Named export | export function parse() {} | Libraries with several utilities |
| Default export | export default class Editor {} | Modules centered on one main value |
| Named import | import { parse } from './util.js' | Picking specific bindings |
| Namespace import | import * as util from './util.js' | Grouping all exports under one object |
| Re-export | export { parse } from './util.js' | Building a public entry file |
| Dynamic import | await import('./chart.js') | Loading code on demand |
Several behaviors differ from classic scripts. Module code always runs in strict mode, top-level this is undefined, and top-level variables do not become properties of the global object. Module scripts are deferred by default, so they execute after the document is parsed and in document order, unless the async attribute is added. Top-level await is allowed, which means a module can pause its own evaluation, and the evaluation of modules that depend on it, until a promise settles.
In the browser, module specifiers must be URLs: relative paths starting with ./ or ../, absolute paths, or full URLs. A bare specifier such as lodash fails unless an import map tells the browser which URL it represents. Import maps, declared in a script type="importmap" element, give browsers the package-name resolution that bundlers and Node.js otherwise provide.
ES modules compared with older module patterns
ES modules replaced a collection of community conventions that emerged before the language had a module system. Understanding the differences explains many migration problems.
| System | Loading model | Resolution time | Main environment |
|---|---|---|---|
| ES modules | Asynchronous, graph-based | Static, before execution | Browsers and modern Node.js |
| CommonJS | Synchronous require() | Dynamic, during execution | Older Node.js packages |
| AMD | Asynchronous callbacks | Dynamic, at runtime | Legacy browser applications |
| Script globals | Ordered script tags | None, shared global scope | Very old sites and snippets |
The static structure of ESM is its decisive advantage. Tools can perform tree shaking, removing exports that no file imports, and editors can follow references reliably. CommonJS, by contrast, can compute the argument of require() at runtime, so its graph is only fully known while the program runs. Interoperability between the two systems remains a source of friction, especially around default exports and packages that ship both formats.
Using ES modules in production
Native ES modules work in all current major browsers, yet most production sites still pass them through a module bundler. The reason is network cost: an unbundled graph with hundreds of small files creates request waterfalls, because the browser discovers deeper imports only after parsing the files above them.
The decision table below shows how teams usually choose a delivery strategy.
| Situation | Recommended approach | Reason |
|---|---|---|
| Small site with few modules | Native modules without a build | Simple setup, acceptable request count |
| Large application | Bundle with code splitting | Fewer requests, smaller initial download |
| Deep native module graph | Add modulepreload hints | Fetches nested dependencies earlier |
| Third-party packages by name | Import map or bundler | Browsers cannot resolve bare specifiers |
| Rarely used feature | Dynamic import | Keeps the code out of the first load |
The link rel="modulepreload" hint lets the browser fetch and parse a module ahead of time, shortening the waterfall when native modules are served directly. Dynamic import() is also the mechanism behind code splitting: bundlers turn each dynamic import into a separate chunk that loads only when needed.
Common pitfalls with ES modules
Most failures with ES modules come from serving, resolution and evaluation-order details rather than from the syntax itself.
- Wrong MIME type: the server must deliver module files with a JavaScript content type, otherwise the browser refuses to execute them.
- Missing file extensions: browsers do not guess
.js, so./utilfails where a bundler would have resolved it. - Cross-origin modules: module scripts are fetched in CORS mode, so files from another origin need the correct response headers.
- Opening files from disk: pages loaded through
file://usually cannot load modules, so a local server is required. - Circular dependencies: a cycle can expose a binding before it is initialized, which throws a reference error during evaluation.
- Overusing default exports: they make renaming inconsistent across files and weaken automated refactoring.
Unlike classic scripts, a module referenced several times is evaluated only once, so side effects such as registering an event listener do not repeat. Developers who rely on re-execution must export an explicit initialization function instead.
In simple terms
ES modules let a programmer split a large JavaScript program into separate files with clear connections. Each file states what it shares with others. It also states what it needs from them. Think of each file as a room with labeled doors. One door hands out specific tools, and other doors request tools from neighboring rooms.
These requests are written down before anything runs. The browser can therefore read the whole floor plan first. It fetches every room it needs and connects them correctly. The result is organized code without name collisions. Such code is easier to maintain, test and load.
A practical example
A team builds an online invoicing tool. Its main screen needs a table component, a date helper and a currency formatter, while a heavy charting library is used only on the reports page. Each part lives in its own ES module, and the entry file imports the three small modules with static import statements.
The charting code is loaded with dynamic import when a user opens the reports page. The bundler turns that call into a separate chunk, so the initial download stays small and the main screen becomes interactive sooner. During development the team serves native modules with an import map, which keeps the edit-and-reload cycle fast, and in production the same source is bundled with code splitting. The outcome is one codebase, two delivery modes, and no global variables shared between files.