ES modules

Also known as: ECMAScript modules · ESM

On this page 7

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.

PhaseWhat happensTypical failure
ConstructionFetch, parse and discover importsNetwork error, wrong MIME type, syntax error
InstantiationLink imports to exports in memoryMissing export name, unresolved specifier
EvaluationRun module code once, dependencies firstRuntime exception, access before initialization
Phases of ES module loading

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.

FormExampleTypical use
Named exportexport function parse() {}Libraries with several utilities
Default exportexport default class Editor {}Modules centered on one main value
Named importimport { parse } from './util.js'Picking specific bindings
Namespace importimport * as util from './util.js'Grouping all exports under one object
Re-exportexport { parse } from './util.js'Building a public entry file
Dynamic importawait import('./chart.js')Loading code on demand
Common import and export forms

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.

SystemLoading modelResolution timeMain environment
ES modulesAsynchronous, graph-basedStatic, before executionBrowsers and modern Node.js
CommonJSSynchronous require()Dynamic, during executionOlder Node.js packages
AMDAsynchronous callbacksDynamic, at runtimeLegacy browser applications
Script globalsOrdered script tagsNone, shared global scopeVery old sites and snippets
Module systems used in JavaScript

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.

SituationRecommended approachReason
Small site with few modulesNative modules without a buildSimple setup, acceptable request count
Large applicationBundle with code splittingFewer requests, smaller initial download
Deep native module graphAdd modulepreload hintsFetches nested dependencies earlier
Third-party packages by nameImport map or bundlerBrowsers cannot resolve bare specifiers
Rarely used featureDynamic importKeeps the code out of the first load
Choosing how to ship ES modules

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 ./util fails 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.