Skip to content
JavaScript js modules 4 min read

JavaScript Modules

A module is a self-contained file of JavaScript that explicitly declares what it exposes to the outside world and what it depends on from elsewhere. Modules let you split a large application into small, focused files that can be reused, tested, and reasoned about in isolation. Before native modules existed, JavaScript had no built-in way to do this, and developers leaned on fragile conventions that broke down as apps grew. This page explains the problem modules solve, how the ecosystem arrived at the modern standard (ES Modules), and what “module scope” actually means.

The problem: everything was global

For most of its history, JavaScript loaded code through <script> tags. Every variable and function declared at the top level of a script landed on the shared global object (window in the browser, global in older Node). With several scripts on a page, this created predictable disasters.

<script src="utils.js"></script>
<script src="app.js"></script>
// utils.js
function formatPrice(value) {
  return `$${value.toFixed(2)}`;
}

// app.js — relies on load order, and any other script can clobber formatPrice
console.log(formatPrice(9.5));

Output:

$9.50

Three pain points dominated this era. Name collisions: two libraries could each define a format function and silently overwrite one another. Implicit dependencies: app.js needed utils.js, but nothing in the code said so — you had to remember the right <script> order by hand. No reuse boundary: there was no clean way to share code between files without polluting the global namespace.

First workaround: the IIFE

The community’s first fix was the Immediately Invoked Function Expression (IIFE). By wrapping code in a function that runs instantly, internal variables stay private, and you expose only what you choose by returning an object.

const PriceUtils = (function () {
  // private — invisible to the rest of the page
  const currency = "$";

  function formatPrice(value) {
    return `${currency}${value.toFixed(2)}`;
  }

  return { formatPrice };
})();

console.log(PriceUtils.formatPrice(9.5));

Output:

$9.50

This “module pattern” tamed the global namespace to a single object per library, but it was still a convention, not a language feature. Dependencies were passed in manually, and tooling could not reliably analyze what a file imported or exported.

Standardizing: CommonJS and AMD

As Node.js arrived and front-end apps ballooned, two formal module systems emerged. CommonJS (Node) uses require() and module.exports, loading modules synchronously from disk. AMD (browser, via RequireJS) loaded modules asynchronously to suit the network.

// CommonJS (Node)
const { formatPrice } = require("./price-utils");
module.exports = { formatPrice };

These were a huge step forward, but they were competing, non-native solutions. CommonJS could not run unbundled in browsers, and mixing the two required build tooling and glue code.

The standard: ES Modules (ESM)

ES2015 introduced ES Modules, the official module system built into the language. It uses import and export keywords, works natively in both modern browsers and Node, and is statically analyzable — tools can determine the dependency graph without executing code, which enables tree-shaking and faster bundles.

// price-utils.js
export function formatPrice(value) {
  return `$${value.toFixed(2)}`;
}

// app.js
import { formatPrice } from "./price-utils.js";
console.log(formatPrice(9.5));

Output:

$9.50

The declared dependency is now part of the code itself — no load-order guessing, no global pollution.

Module scope

Every ES Module has its own top-level scope. Variables, functions, and classes declared at the top of a module are local to that module unless explicitly exported — they never touch the global object. Modules also behave differently from classic scripts in a few important ways.

BehaviorClassic scriptES Module
Top-level var/functionAdded to global objectModule-scoped, private
Strict modeOpt-in ("use strict")Always on
Top-level thiswindow / globalThisundefined
EvaluationOnce, in source orderOnce, cached after first import
Top-level awaitNot allowedAllowed
// counter.js — state is private to the module and shared by all importers
let count = 0;
export const increment = () => (count += 1);
export const current = () => count;

A module’s code runs once. The first import evaluates it; every later import of the same specifier reuses the cached result. That makes a module a natural place for shared singletons — but it also means top-level side effects fire exactly once, which can surprise you if you expect a fresh instance per import.

Best practices

  • Prefer ES Modules for all new code — they are the language standard and work across browsers, bundlers, and modern Node.
  • Keep each module focused on a single responsibility; small modules compose and test more easily.
  • Export only what callers genuinely need, and keep internal helpers unexported to preserve a clean public surface.
  • Declare dependencies with explicit import statements rather than relying on globals or implicit load order.
  • Avoid heavy side effects at the top level of a module, since that code runs the moment the module is first imported.
  • In Node, set "type": "module" in package.json (or use the .mjs extension) so files are parsed as ESM.
Last updated June 1, 2026
Was this helpful?