How JavaScript went from global script tags to a standardized native module system. Understanding the history helps you appreciate why ES Modules work the way they do.
JavaScript was created in 1995 with no module system. All scripts shared a single global scope, leading to naming conflicts and dependency chaos.
<!-- The original "module system" - order matters! -->
<script src="jquery.js"></script>
<script src="jquery-plugin.js"></script>
<script src="app.js"></script>
<!-- Problems: -->
<!-- 1. Everything in global scope (window.jQuery, window.$) -->
<!-- 2. Script order is critical - wrong order = errors -->
<!-- 3. No dependency declaration -->
<!-- 4. Name collisions between libraries -->
Developers invented the Immediately Invoked Function Expression to create private scope:
// IIFE - Immediately Invoked Function Expression
var MyModule = (function() {
// Private variables - not accessible outside
var privateData = 'secret';
// Public API - returned object
return {
getData: function() {
return privateData;
}
};
})();
MyModule.getData(); // 'secret'
// privateData is not accessible directly
This was clever but manual - developers had to carefully manage the global namespace and dependency order.
Node.js adopted CommonJS, giving JavaScript its first practical module system. Synchronous loading worked well for servers but not browsers.
// math.js - CommonJS module
const PI = 3.14159;
function circleArea(r) {
return PI * r * r;
}
module.exports = { circleArea, PI };
// app.js - require() is synchronous
const { circleArea } = require('./math');
console.log(circleArea(5)); // 78.54
Impact:
CommonJS enabled the npm ecosystem to explode. By 2026, npm hosts over 3 million packages. But the synchronous require() was fundamentally incompatible with browser needs.
AMD (Asynchronous Module Definition) solved the browser problem with async loading:
// AMD - Asynchronous Module Definition
define(['jquery', 'lodash'], function($, _) {
// Module code here
return {
init: function() {
$('.app').html(_.template('Hello'));
}
};
});
// RequireJS loaded modules asynchronously
// Good for browsers, but verbose syntax
UMD (Universal Module Definition) tried to work everywhere - AMD, CommonJS, and global scope:
// UMD - works in AMD, CommonJS, and browser globals
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define(['dependency'], factory); // AMD
} else if (typeof module === 'object') {
module.exports = factory(require('dependency')); // CJS
} else {
root.MyLib = factory(root.Dependency); // Browser global
}
}(typeof self !== 'undefined' ? self : this, function(dep) {
// Module code here
return { /* public API */ };
}));
UMD was ugly but practical. Many libraries shipped UMD builds to maximize compatibility. Some still do today for legacy support.
Browserify brought CommonJS require() to the browser by bundling all dependencies into a single file:
# Bundle Node-style require() for browsers
browserify main.js -o bundle.js
// Now require() works in the browser!
const _ = require('lodash');
const myModule = require('./my-module');
webpack introduced the concept of a module bundler that could handle any asset type - not just JavaScript:
// webpack.config.js - bundles everything
module.exports = {
entry: './src/index.js',
output: { filename: 'bundle.js' },
module: {
rules: [
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
{ test: /\.png$/, use: 'file-loader' }
]
}
};
// Code could now import anything
import styles from './app.css';
import logo from './logo.png';
webpack dominated JavaScript tooling for years and pioneered features like code splitting, hot module replacement, and tree-shaking.
Rollup was the first bundler built specifically for ES Modules, pioneering tree-shaking (dead code elimination):
// Rollup leveraged ES Module static structure
// to eliminate unused code (tree-shaking)
// utils.js
export function used() { /* ... */ }
export function unused() { /* ... */ }
// app.js
import { used } from './utils.js';
// Rollup removes unused() from the final bundle!
ECMAScript 2015 finally specified a native module system with static import/export syntax:
// The syntax we know today - standardized in 2015
export function add(a, b) { return a + b; }
export default class Calculator { /* ... */ }
import Calculator, { add } from './math.js';
// Key design decisions:
// - Static structure (analyzable at compile time)
// - Asynchronous loading (browser-friendly)
// - Live bindings (not value copies)
// - Strict mode by default
| Year | Milestone |
|---|---|
| 2015 | ES2015 specification published with module syntax |
| 2017 | Safari 10.1 ships first browser ESM support; Chrome 61 follows |
| 2018 | Firefox 60, Edge 16 add support. Dynamic import() proposed |
| 2019 | Node.js 12 adds experimental ESM. Dynamic import() standardized |
| 2020 | Top-level await reaches Stage 3. Vite launches (ESM-native dev server) |
| 2021 | Node.js 16 stabilizes ESM. Top-level await standardized (ES2022) |
| 2023 | Import maps supported in all browsers. Deno, Bun go ESM-native |
| 2025 | Import attributes and JSON modules standardized (ES2025). Node 23 enables require(esm) |
| 2026 | Import defer and source phase imports advancing. Rust-based tooling (Rolldown, Oxc) becomes mainstream |
ES Modules are now the undisputed standard across all JavaScript environments:
with keywordThe Bottom Line:
After 11 years of evolution since ES2015, the JavaScript module story is settled. ES Modules are the universal standard. New projects should always use ESM, and the ecosystem provides excellent tooling for migrating legacy code.
Before ES Modules, JavaScript used several unofficial module patterns: IIFEs for scope isolation, AMD (Asynchronous Module Definition) with RequireJS for browser modules, and CommonJS with require() for Node.js. Bundlers like Browserify and webpack bridged the gap by compiling modules for browsers.
ES Modules were specified in ECMAScript 2015 (ES6) in June 2015. Browser support arrived in 2017-2018 (Chrome 61, Firefox 60, Safari 10.1). Node.js added experimental ESM support in version 12 (2019) and stabilized it in version 16 (2021).
JavaScript originally had no built-in module system, leading to global scope pollution, naming conflicts, and dependency management problems. A native module system enables static analysis for tree-shaking, standardized syntax across environments, and better tooling support.
Yes, but decreasingly. Node.js 23+ allows require() of ES Modules, reducing friction. Most new packages ship as ESM-only or dual ESM/CJS. The ecosystem is steadily migrating to ES Modules as the universal standard.