JavaScript Modules
JavaScript modules are a way to split code into separate files, each with its own scope. Before modules were standardized, global variables were the primary mechanism for sharing code across files, which led to naming collisions, implicit dependencies, and fragile code that was difficult to reason about or refactor. The module pattern solves all of these problems by providing explicit boundaries between units of code.
Modules provide three fundamental benefits. First, encapsulation — each module has its own private scope; variables and functions declared inside a module are not visible outside unless explicitly exported. This prevents accidental global pollution and creates clean public APIs. Second, reusability — modules can be imported by any other module, enabling a composable architecture where small, focused modules are combined to build larger systems. Third, dependency management — import/export declarations make dependencies explicit and create a clear dependency graph that tools like bundlers can analyze statically.
Modern JavaScript supports two major module systems: ES Modules (ESM), the official standard integrated into the language specification, and CommonJS (CJS), the module system originally created for Node.js. Understanding both is essential because the JavaScript ecosystem spans browsers, servers, build tools, and package managers, each with its own module conventions.
| 1 | // Before modules: global scope and IIFE patterns |
| 2 | var globalCounter = 0; // pollutes window |
| 3 | |
| 4 | var MyModule = (function () { |
| 5 | var privateVar = "secret"; |
| 6 | return { |
| 7 | getSecret: function () { return privateVar; } |
| 8 | }; |
| 9 | })(); |
| 10 | |
| 11 | // With ES modules: clean, explicit, scoped |
| 12 | // math.js |
| 13 | export function add(a, b) { return a + b; } |
| 14 | |
| 15 | // app.js |
| 16 | import { add } from "./math.js"; |
| 17 | console.log(add(2, 3)); // 5 |
note
ES Modules (ESM) are the official JavaScript module system defined in ES2015 (ES6). Every module is a file, and the file extension .js or .mjs tells the runtime how to treat it. Modules use the import and export keywords, which are parsed statically (before execution begins), enabling powerful compile-time optimizations.
In the browser, modules are loaded via the <script type="module"> tag. Module scripts are deferred by default, meaning they execute after the HTML is parsed. They also have strict mode enabled by default, and they do not create global variables — every top-level declaration is scoped to the module. Modules are fetched with CORS and respect the same-origin policy.
Module specifiers (the string passed to import) distinguish between bare specifiers (like "lodash") and relative/absolute specifiers (like "./utils.js" or "/lib/helpers.js"). Bare specifiers require an import map or a bundler to resolve. Relative specifiers work natively in browsers.
| 1 | <!DOCTYPE html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="UTF-8" /> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | <title>ES Module Demo</title> |
| 7 | <!-- Module script — deferred, strict, scoped --> |
| 8 | <script type="module" src="app.js"></script> |
| 9 | <!-- Inline module script --> |
| 10 | <script type="module"> |
| 11 | import { version } from "./config.js"; |
| 12 | console.log("App version:", version); |
| 13 | </script> |
| 14 | </head> |
| 15 | <body> |
| 16 | <h1>Modules in Browser</h1> |
| 17 | </body> |
| 18 | </html> |
| 1 | // Modules are singleton scopes |
| 2 | // config.js |
| 3 | export const version = "1.0.0"; |
| 4 | export const author = "Blueprint Team"; |
| 5 | const internalToken = "sk-..."; // NOT exported — private |
| 6 | |
| 7 | // app.js — imports are hoisted to the top of the module |
| 8 | import { version, author } from "./config.js"; |
| 9 | |
| 10 | console.log(version); // "1.0.0" |
| 11 | console.log(author); // "Blueprint Team" |
| 12 | console.log(internalToken); // ReferenceError: not accessible |
Named exports allow a module to export multiple values, each with a specific name. There are two syntaxes: inline exports, where the export keyword is placed directly before a declaration, and bottom-of-file exports, where a single export { ... } statement lists all exports at the end of the module. Both approaches produce the same result, but the bottom-of-file pattern makes it easier to see the complete public API at a glance.
When importing named exports, you destructure them using curly braces. You can also rename imports using the as keyword to avoid naming conflicts or to provide shorter aliases. Named exports are the preferred way to expose utility functions, constants, and multiple values from a module.
Imported named exports are live bindings, not copies. If the exporting module changes the value, the importing module sees the change. However, importing modules cannot reassign exported bindings (they are read-only views).
| 1 | // ─── Inline exports ─── |
| 2 | |
| 3 | // operations.js |
| 4 | export const PI = 3.14159; |
| 5 | |
| 6 | export function circleArea(radius) { |
| 7 | return PI * radius * radius; |
| 8 | } |
| 9 | |
| 10 | export class Calculator { |
| 11 | static add(a, b) { return a + b; } |
| 12 | } |
| 13 | |
| 14 | // ─── Bottom-of-file exports (same result) ─── |
| 15 | |
| 16 | // utils/math.js |
| 17 | const PI = 3.14159; |
| 18 | |
| 19 | function circleArea(radius) { |
| 20 | return PI * radius * radius; |
| 21 | } |
| 22 | |
| 23 | function circumference(radius) { |
| 24 | return 2 * PI * radius; |
| 25 | } |
| 26 | |
| 27 | export { PI, circleArea, circumference }; |
| 28 | |
| 29 | // ─── Importing named exports ─── |
| 30 | |
| 31 | // app.js |
| 32 | import { PI, circleArea, Calculator } from "./operations.js"; |
| 33 | import { circumference as circ } from "./utils/math.js"; |
| 34 | |
| 35 | console.log(circleArea(5)); // 78.53975 |
| 36 | console.log(circ(5)); // 31.4159 |
| 37 | console.log(Calculator.add(2, 3)); // 5 |
| 38 | |
| 39 | // ─── Import all named exports as a namespace ─── |
| 40 | |
| 41 | import * as MathUtils from "./utils/math.js"; |
| 42 | console.log(MathUtils.PI); // 3.14159 |
best practice
A module can have at most one default export. Default exports are typically used when the module represents a single main concept — a class, a primary function, or a component. The syntax uses export default followed by the value (which can be an expression, so the declaration does not need a name).
When importing a default export, the curly braces are omitted and you can assign any name to the import. This is convenient but can lead to inconsistent naming across a codebase. Default exports cannot be tree-shaken because the bundler cannot know which properties of the default export are used at compile time.
It is possible to mix named exports with a default export in the same module. The default export is effectively a named export with the special name default.
| 1 | // ─── Default export (function) ─── |
| 2 | |
| 3 | // greeter.js |
| 4 | export default function greet(name) { |
| 5 | return "Hello, " + name + "!"; |
| 6 | } |
| 7 | |
| 8 | // ─── Default export (class) ─── |
| 9 | |
| 10 | // user.js |
| 11 | export default class User { |
| 12 | constructor(name, email) { |
| 13 | this.name = name; |
| 14 | this.email = email; |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | // ─── Default export (anonymous expression) ─── |
| 19 | |
| 20 | // logger.js |
| 21 | export default { |
| 22 | log(msg) { console.log("[LOG]", msg); }, |
| 23 | error(msg) { console.error("[ERR]", msg); }, |
| 24 | }; |
| 25 | |
| 26 | // ─── Importing default exports ─── |
| 27 | |
| 28 | import greet from "./greeter.js"; |
| 29 | import User from "./user.js"; |
| 30 | import logger from "./logger.js"; |
| 31 | |
| 32 | console.log(greet("World")); |
| 33 | |
| 34 | const u = new User("Alice", "alice@example.com"); |
| 35 | |
| 36 | // ─── Mixing default and named ─── |
| 37 | |
| 38 | // api.js |
| 39 | export default function request(url) { ... } |
| 40 | export const BASE_URL = "https://api.example.com"; |
| 41 | export function parseResponse(data) { ... } |
| 42 | |
| 43 | // app.js |
| 44 | import request, { BASE_URL, parseResponse } from "./api.js"; |
| 45 | |
| 46 | // ─── The default export is just "default" behind the scenes ─── |
| 47 | export { request as default, BASE_URL, parseResponse }; |
| 48 | |
| 49 | // ─── Re-exporting defaults ─── |
| 50 | export { default as Greeter } from "./greeter.js"; |
pro tip
One of the most important properties of ES modules is that they execute in strict mode by default. You do not need to write "use strict" at the top — it is implied. Strict mode changes several language semantics: silent errors become thrown errors, assignments to non-writable globals throw, and the this keyword in top-level functions is undefined instead of the global object.
Module scope means that every top-level declaration (variables, functions, classes) is private to the module. No implicit globals are created. In a non-module script, var x = 1 at the top level creates a property on window. In a module, it creates a module-scoped variable that is invisible to other modules unless explicitly exported.
The top-level this in a module is undefined, not window. This is a common gotcha when refactoring old code into modules. If you need to access the global object, use globalThis, which works in all contexts (modules, workers, Node.js).
| 1 | // module-scope.js — runs as an ES module |
| 2 | "use strict"; // IMPLICIT — no need to write this |
| 3 | |
| 4 | // No implicit globals |
| 5 | var x = 42; // module-scoped, NOT on window |
| 6 | let y = "hello"; // module-scoped |
| 7 | const z = true; // module-scoped |
| 8 | |
| 9 | // Top-level this is undefined |
| 10 | console.log(this); // undefined (not window) |
| 11 | |
| 12 | // globalThis works everywhere |
| 13 | console.log(globalThis); // the global object |
| 14 | |
| 15 | // Strict mode differences: |
| 16 | // 1. Silent errors become thrown |
| 17 | // 2. Octal literals (0777) are syntax errors |
| 18 | // 3. with() is forbidden |
| 19 | // 4. NaN = 1 throws instead of silently failing |
| 20 | // 5. Functions must be declared at top level only |
| 21 | |
| 22 | function strictTest() { |
| 23 | // Cannot access the global object through 'this' |
| 24 | console.log(this); // undefined |
| 25 | } |
| 26 | |
| 27 | // Private to module — not visible to other files |
| 28 | const internalConfig = { |
| 29 | debug: true, |
| 30 | logLevel: "info", |
| 31 | }; |
| 32 | |
| 33 | // Only exported bindings are visible externally |
| 34 | export const publicConfig = { |
| 35 | version: "1.0.0", |
| 36 | }; |
warning
Static import declarations are evaluated before the module body executes and can only appear at the top level of a module. Dynamic imports, using the import() function, allow you to load modules on demand at runtime. The import() function returns a promise that resolves to the module namespace object.
Dynamic imports enable several important patterns. Lazy loading — loading code only when it is needed, reducing initial bundle size and improving page load performance. Code splitting — bundlers like webpack, Rollup, and Vite use dynamic import boundaries to split output into separate chunks that are loaded on demand. Conditional loading — importing different modules based on runtime conditions like user preferences, feature flags, or device capabilities.
Dynamic imports work in both module and non-module scripts. They are also supported in Node.js for both ESM and CJS contexts (though in CJS, import() is the only way to load ESM modules).
| 1 | // ─── Dynamic import returns a promise ─── |
| 2 | |
| 3 | // Basic lazy loading |
| 4 | const modulePath = "./heavy-library.js"; |
| 5 | |
| 6 | import(modulePath) |
| 7 | .then((module) => { |
| 8 | module.doSomething(); |
| 9 | }) |
| 10 | .catch((err) => { |
| 11 | console.error("Failed to load module:", err); |
| 12 | }); |
| 13 | |
| 14 | // ─── Using async/await ─── |
| 15 | |
| 16 | async function loadDashboard() { |
| 17 | try { |
| 18 | const { renderChart, formatData } = await import("./dashboard.js"); |
| 19 | const data = await fetch("/api/stats"); |
| 20 | renderChart(formatData(data)); |
| 21 | } catch (err) { |
| 22 | showFallbackUI(); |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | document.getElementById("dashboard-btn") |
| 27 | .addEventListener("click", loadDashboard); |
| 28 | |
| 29 | // ─── Conditional loading ─── |
| 30 | |
| 31 | async function loadFormatter(locale) { |
| 32 | if (locale === "de-DE") { |
| 33 | return import("./formatters/de.js"); |
| 34 | } else if (locale === "ja-JP") { |
| 35 | return import("./formatters/ja.js"); |
| 36 | } |
| 37 | return import("./formatters/en.js"); |
| 38 | } |
| 39 | |
| 40 | // ─── Dynamic import with default export ─── |
| 41 | |
| 42 | const { default: ChartLib } = await import("./chart-library.js"); |
| 43 | |
| 44 | // ─── Code splitting boundary example (bundler) ─── |
| 45 | |
| 46 | // routes.js (conceptual with React Router) |
| 47 | const routes = [ |
| 48 | { path: "/", component: () => import("./pages/Home.js") }, |
| 49 | { path: "/settings", component: () => import("./pages/Settings.js") }, |
| 50 | { path: "/admin", component: () => import("./pages/Admin.js") }, |
| 51 | ]; |
info
CommonJS (CJS) is the module system originally designed for Node.js, using require() and module.exports. It is synchronous, which is fine for server-side loading from disk but not suitable for browsers. ESM is the official ECMAScript standard, supports both sync and async loading, and can be statically analyzed.
The two systems differ in many fundamental ways: when they are evaluated, how bindings work, whether they support tree-shaking, and how circular dependencies are handled. Understanding the differences is crucial when working in hybrid environments like Node.js with mixed ESM/CJS packages.
| Feature | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| Syntax | require() | import / export |
| Loading | Synchronous | Async (static + dynamic) |
| Evaluation | Runtime | Parse-time (static analysis) |
| Bindings | Copied values (primitives), reference for objects | Live bindings (read-only views) |
| Tree-shaking | Not supported | Supported (static exports) |
| Top-level this | module.exports | undefined |
| Strict mode | Opt-in | Default |
| Circular deps | Partial exports (may get undefined) | Live bindings (always up-to-date) |
| File extension | .js / .cjs | .js / .mjs |
| Browser support | Bundler only | Native |
| Dynamic loading | require() inline | import() function |
| Named exports | exports.foo = ... | export { foo } |
| 1 | // ─── CommonJS (CJS) ─── |
| 2 | |
| 3 | // utils.js — exporting |
| 4 | const PI = 3.14159; |
| 5 | |
| 6 | function add(a, b) { return a + b; } |
| 7 | |
| 8 | module.exports = { |
| 9 | PI, |
| 10 | add, |
| 11 | }; |
| 12 | // Or: exports.PI = PI; exports.add = add; |
| 13 | |
| 14 | // app.js — importing |
| 15 | const { PI, add } = require("./utils.js"); |
| 16 | |
| 17 | console.log(add(PI, 2)); // 5.14159 |
| 18 | |
| 19 | // ─── ES Module (ESM) ─── |
| 20 | |
| 21 | // utils.js — exporting |
| 22 | export const PI = 3.14159; |
| 23 | export function add(a, b) { return a + b; } |
| 24 | |
| 25 | // app.js — importing |
| 26 | import { PI, add } from "./utils.js"; |
| 27 | |
| 28 | console.log(add(PI, 2)); // 5.14159 |
| 29 | |
| 30 | // ─── Key behavioral difference: bindings ─── |
| 31 | |
| 32 | // counter.cjs |
| 33 | let count = 0; |
| 34 | module.exports = { count, increment: () => count++ }; |
| 35 | |
| 36 | // app.cjs — count is a COPY at require time |
| 37 | const { count, increment } = require("./counter.cjs"); |
| 38 | increment(); |
| 39 | increment(); |
| 40 | console.log(count); // 0 — still the original copy! |
| 41 | |
| 42 | // counter.mjs |
| 43 | export let count = 0; |
| 44 | export const increment = () => count++; |
| 45 | |
| 46 | // app.mjs — count is a LIVE BINDING |
| 47 | import { count, increment } from "./counter.mjs"; |
| 48 | increment(); |
| 49 | increment(); |
| 50 | console.log(count); // 2 — live binding, always current |
danger
Tree shaking is a dead-code elimination technique performed by bundlers (webpack, Rollup, Vite, esbuild). It analyzes the static structure of ES modules — specifically, the import and export declarations — to determine which exports are actually used by the application. Any exports that are never imported can be safely removed from the final bundle, reducing file size.
Tree shaking works because ES module syntax is static. The import and export keywords must appear at the top level of a module and cannot be wrapped in conditionals or loops. This means the bundler can determine the dependency graph without executing any code — it just parses the AST (Abstract Syntax Tree).
The concept of side effects is critical for tree shaking. If a module has side effects (code that runs on import, like modifying globals, setting up polyfills, or registering custom elements), the bundler cannot safely remove it even if no exports are used. Package authors use the "sideEffects": false field in package.json to declare that their package is side-effect-free, enabling more aggressive tree shaking.
| 1 | // ─── Tree-shakeable module ─── |
| 2 | |
| 3 | // utils.js — multiple named exports |
| 4 | export function add(a, b) { return a + b; } |
| 5 | export function subtract(a, b) { return a - b; } |
| 6 | export function multiply(a, b) { return a * b; } |
| 7 | export function divide(a, b) { return a / b; } |
| 8 | |
| 9 | // app.js — only imports add, ignoring the rest |
| 10 | import { add } from "./utils.js"; |
| 11 | console.log(add(2, 3)); // 5 |
| 12 | // subtract, multiply, divide are tree-shaken away |
| 13 | |
| 14 | // ─── What PREVENTS tree shaking ─── |
| 15 | |
| 16 | // 1. Side effects at module level |
| 17 | // bad-effects.js |
| 18 | console.log("Module loaded!"); // side effect! |
| 19 | Array.prototype.customMethod = function () {}; // side effect! |
| 20 | export const data = [1, 2, 3]; |
| 21 | |
| 22 | // 2. Dynamic property access on exports |
| 23 | // The bundler cannot know which key is accessed at compile time |
| 24 | import * as utils from "./utils.js"; |
| 25 | const method = "add"; |
| 26 | utils[method](2, 3); // prevents tree-shaking of ALL exports |
| 27 | |
| 28 | // 3. Default exports on objects (cannot be analyzed) |
| 29 | export default { |
| 30 | foo: () => {}, |
| 31 | bar: () => {}, |
| 32 | }; |
| 33 | import lib from "./library.js"; |
| 34 | lib.foo(); // bundler cannot know bar is unused |
| 35 | |
| 36 | // ─── Side effects in package.json ─── |
| 37 | |
| 38 | // package.json |
| 39 | { |
| 40 | "name": "my-library", |
| 41 | "sideEffects": false, |
| 42 | // or specify files with side effects: |
| 43 | "sideEffects": [ |
| 44 | "./polyfills.js", |
| 45 | "*.css" |
| 46 | ] |
| 47 | } |
best practice
A circular dependency occurs when two or more modules depend on each other: module A imports from B, and B imports (directly or transitively) from A. In well-designed systems, circular dependencies are rare and often indicate that modules should be refactored or that a shared dependency should be extracted. However, they do sometimes arise in practice, and it is important to understand how each module system handles them.
In CommonJS, when a circular dependency is encountered, require() returns whatever has been assigned to module.exports at the time the circular call happens. If module B's exports are not yet fully populated, the importing module may get an incomplete object or undefined for certain properties. This is the source of many subtle bugs.
ES Modules handle circular dependencies more gracefully through live bindings. Because exports are live bindings rather than copies, even if module B is still being evaluated when module A references its exports, the binding remains connected. Once module B finishes evaluating, the values become available. This means ESM circular dependencies are more likely to work correctly, but they still require careful design.
| 1 | // ─── Circular dependency in CJS (problematic) ─── |
| 2 | |
| 3 | // a.js |
| 4 | const b = require("./b.js"); |
| 5 | console.log("a.js: b.message =", b.message); |
| 6 | module.exports = { message: "Hello from A" }; |
| 7 | |
| 8 | // b.js |
| 9 | const a = require("./a.js"); // a is partially loaded! |
| 10 | // a.message is undefined at this point! |
| 11 | console.log("b.js: a.message =", a.message); |
| 12 | module.exports = { message: "Hello from B" }; |
| 13 | |
| 14 | // main.js |
| 15 | const a = require("./a.js"); |
| 16 | // Output: |
| 17 | // b.js: a.message = undefined ← incomplete |
| 18 | // a.js: b.message = Hello from B |
| 19 | // console.log(a.message) → Hello from A |
| 20 | |
| 21 | // ─── Circular dependency in ESM (live bindings) ─── |
| 22 | |
| 23 | // a.mjs |
| 24 | import { message as bMsg } from "./b.mjs"; |
| 25 | console.log("a.mjs: b.message =", bMsg); |
| 26 | export const message = "Hello from A"; |
| 27 | |
| 28 | // b.mjs |
| 29 | import { message as aMsg } from "./a.mjs"; |
| 30 | // bMsg is a LIVE BINDING — it will be resolved |
| 31 | console.log("b.mjs: a.message =", aMsg); |
| 32 | export const message = "Hello from B"; |
| 33 | |
| 34 | // main.mjs |
| 35 | import { message } from "./a.mjs"; |
| 36 | // Output: |
| 37 | // b.mjs: a.message = undefined ← not yet initialized |
| 38 | // a.mjs: b.message = Hello from B |
| 39 | // console.log(message) → Hello from A |
warning
One Default Export Per Module
A module should have a single clear responsibility. If it has a default export, that default should represent the primary purpose of the module. Additional utility functions can be named exports alongside a default, but if a module has more than a few exports it might be doing too much.
Use Named Exports for Utilities
Utility functions, constants, types, and helper values should be named exports. This enables tree-shaking, makes it obvious what is available, and forces consumers to explicitly name what they want. Named exports also create a consistent pattern across your codebase.
Avoid Side Effects in Modules
Side effects at module load time — console.log, DOM manipulation, modifying globals, patching prototypes — prevent tree-shaking and make modules unpredictable. Keep module initialization pure. If side effects are required (like polyfills or CSS imports), isolate them in dedicated files and declare them in "sideEffects" in package.json.
Barrel Files
A barrel file is an index module that re-exports selected exports from multiple modules in a directory, providing a single import point for consumers. Barrel files simplify imports but can create circular dependencies and hurt tree-shaking if not used carefully.
Modern guidelines suggest using barrel files sparingly. Prefer direct imports when possible. If you use barrels, re-export only what is necessary and avoid deeply nested barrels. Some tools now even recommend against barrels because they can confuse the module graph.
| 1 | // ─── Barrel file pattern ─── |
| 2 | |
| 3 | // components/index.js — barrel file |
| 4 | export { Button } from "./Button.js"; |
| 5 | export { Card } from "./Card.js"; |
| 6 | export { Modal } from "./Modal.js"; |
| 7 | export { Navbar } from "./Navbar.js"; |
| 8 | |
| 9 | // Consumer imports from the barrel |
| 10 | import { Button, Card, Modal } from "./components"; |
| 11 | |
| 12 | // ─── Prefer direct imports in application code ─── |
| 13 | import { Button } from "./components/Button.js"; |
| 14 | |
| 15 | // ─── Recommended module structure ─── |
| 16 | |
| 17 | // calculator.js — one clear responsibility |
| 18 | export function add(a, b) { return a + b; } |
| 19 | export function subtract(a, b) { return a - b; } |
| 20 | |
| 21 | // No side effects at module level |
| 22 | // No global state mutation |
| 23 | // Each function is pure and testable |
| 24 | |
| 25 | // app.js — explicit imports |
| 26 | import { add, subtract } from "./calculator.js"; |
| 27 | |
| 28 | // ─── What to avoid ─── |
| 29 | |
| 30 | // ❌ Side effects at module level |
| 31 | // setup.js |
| 32 | console.log("Setting up..."); // side effect! |
| 33 | window.myLib = {}; // side effect! |
| 34 | |
| 35 | // ❌ Reassigning exports (not possible in ESM) |
| 36 | // export let count = 0; |
| 37 | // setTimeout(() => count = 5, 1000); // allowed for let |
| 38 | |
| 39 | // ❌ This pattern hides the dependency tree |
| 40 | import * as Everything from "./lazy-barrel.js"; |
| 41 | // The bundler cannot tree-shake this |
best practice
The following live preview demonstrates a complete module-based application rendered in a single HTML page using <script type="module">. The embedded JavaScript defines modules inline to show real import/export behavior, including named exports, default exports, and dependency chaining.