You've probably used ES modules in modern JavaScript development, but do you know the history behind their evolution? Understanding the journey from early JavaScript practices to today’s module system will help you appreciate how far we've come and why ES modules are a game changer.
The Early Days: JavaScript's Beginnings
The year is 1995, four years after the first web page was created. Most websites were simple — static pages with text and minimal interactivity. However, developers were quickly looking for ways to make web pages more dynamic.
In this environment, Netscape (the dominant web browser at the time) hired Brendan Eich to create a scripting language that would run directly in the browser. This led to the birth of JavaScript, a language designed to be simple and accessible, especially for non-programmers like web designers. And so he did, completing the first version in 10 days.
Originally, JavaScript was meant to add small enhancements to web pages, like form validation, without needing to round-trip data to the server. However, as websites became more interactive, JavaScript quickly grew beyond its original purpose.
The Global Scope and Early Workarounds
In the early days, all JavaScript code lived in the global scope. As more developers added code to the same page, the risk of name collisions grew. If multiple scripts used the same variable or function name, the code could break in unexpected ways.
To manage this, developers resorted to naming conventions to prevent collisions. If a piece of code was meant to run only once, developers often wrapped it in an IIFE (Immediately Invoked Function Expression). This kept functions and variables scoped within the function, preventing them from polluting the global namespace.
(function init() {
function getData(){
// ...
}
})()
This was good enough at the time as most websites were server side rendered with very little client side logic.
CommonJS: Server-side modules
In 2008 Ryan Dahl created Node.js, a JavaScript runtime for building server side applications. This opened up a whole new world of possibilities, but the lack of a module system meant that developers struggled to manage large codebases.
In 2009, CommonJS was introduced to solve this problem for the server-side. The CommonJS module system allowed developers to define modules, expose functionality, and import other modules. Here's an example of how it worked:
const math = require("./math");
function subtract(a,b) {
return math.add(a,-b);
}
module.exports = {
subtract: subtract
}
With CommonJS, each file is treated as its own module, and modules are imported using the require
function and exported using module.exports
.
Some key features of CommonJS include:
The file extension is optional when requiring a module (e.g., require('./math') automatically looks for math.js).
Module loading is synchronous, meaning the program waits for the module to load before continuing execution.
Later in the article we will see why Ryan Dahl admitted to regretting these 2 design decisions.
AMD: Optimizing the Browser
Around the same time, another module system called AMD (Asynchronous Module Definition) was developed. While CommonJS was primarily focused on server-side JavaScript, AMD was designed to handle client-side JavaScript in the browser.
The key feature of AMD was its ability to load modules asynchronously. This allowed the browser to load only the JavaScript needed for a page at any given time, improving performance by reducing initial page load time. It also solved issues related to dependency resolution, ensuring that a module would only run once its dependencies had finished loading.
AMD's benefits included:
- Smaller JavaScript files loaded on-demand.
- Fewer page load errors due to more predictable module loading.
- Performance optimization through asynchronous loading.
With the rise of npm in 2010 (a package manager for server-side JavaScript), the need for sharing code across the browser and the server became apparent. Enter Browserify, a tool that allowed developers to use CommonJS modules in the browser by transforming the code to be compatible with the browser’s environment.
UMD: The Best of Both Worlds... Sort Of
With the 2 competing standards, CommonJS and AMD. There was a need for a single module system that could work everywhere without the need for a build step. And in 2011, the universal module definition(UMD) was introduced.
UMD combined the best of both worlds, allowing developers to write a module that could run in:
- Node.js (using CommonJS).
- Browsers (using AMD).
- The global scope (if neither CommonJS nor AMD was present).
UMD became very popular amongst library authors with notable libraries like Lodash, Underscore.js, Backbone.js, and Moment.js adpoting it. However, UMD had some significant downsides. While it solved the compatibility problem, it came with the complexity of managing both systems, and it inherited the problems of both AMD and CommonJS.
ES modules: The ultimate modules standard
In 2015, ES Modules (ESM) were introduced as part of the ECMAScript standard, finally offering a native module system for JavaScript. By 2017, all major browsers supported ES modules, and in 2020, Node.js added support as well.
Let's see why ES modules are the best:
1. Convenient Syntax
The following UMD code:
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define([], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS
module.exports = factory();
} else {
// Global
root.add = factory();
}
}(this, function () {
function add(a, b) {
return a + b;
}
return add;
}));
can now be reduced to:
export function add(a, b) {
return a + b;
}
To be fair, no one actually wrote UMD like that. They used tools like umdify to generate that code. But with ES modules being built in we can skip the build step and have a smaller bundle size.
2. Better Optimization
ES modules are static, meaning that tools can analyze the code structure at compile time to determine which code is being used and which is not. This allows for tree shaking, where unused code is removed from the final bundle.
Because CommonJS and AMD modules are dynamic (evaluated at runtime), tree shaking is much less efficient with these systems, often resulting in larger bundles.
3. More Explicit Code
When importing a module with CommonJS, specifiying the file extension is optional.
// load math.js file
const math = require('./math')
But what does math actually refer to? Is it a JavaScript file? A JSON file? An index.js file inside a math directory?
When using static analysis tools like ES Lint,typescript or prettier every require becomes a guessing game.
is it math.js?
is it math.jsx?
is it math.cjs?
is it math.mjs?
is it math.ts?
is it math.tsx?
is it math.mts?
is it math.cts?
is it math/index.js?
is it math/index.jsx?
You get the idea.
Reading a file is expensive. It is a lot less performant than reading from memory. Importing math/index.js
results in 9 IO operations instead of 1! And this guessing game is slowing down our tooling and hurts developer experience.
In ES modules we avoid this mess by having file extensions be mandatory.
4. Asynchronous Loading
Unlike CommonJS, which loads modules synchronously (blocking the entire process until the module is loaded), ES modules are asynchronous. This allows JavaScript to continue executing while the module is being loaded in the background, improving performance — particularly in environments like Node.js.
Migration Challenges: Why It Took So Long
Despite the clear benefits, adopting ES modules was no simple task. Here’s why the transition took so long:
1. Costly Migration
Switching from CommonJS to ES modules was not a trivial change, especially for large projects. The syntax differences, combined with the need for tooling support, made migration a significant effort.
2. Lack of Node.js Support
It took node.js 5 years to fully support ES modules. During this time, developers had to maintain compatibility with both CommonJS (on the server) and ES modules (on the browser). This dual support created a lot of friction in the ecosystem.
3. Compatability issues
Even after Node.js added support for ES modules, CommonJS modules couldn’t load ES modules. While ES modules could load CommonJS modules, the two systems weren’t fully interoperable, creating additional headaches for package authors who had to support both systems.
The Future: ES Modules Are Here to Stay
The future of JavaScript modules is bright, and here are a few key developments that will make ES modules the dominant system moving forward:
1. Node.js 23
In Node.js 23 we finally have the ability to load ES modules from CommonJS.
There is a small caveat: ES module that use top-level await cannot be imported into CommonJS, since await can only be used in asynchronous functions, and CommonJS is synchronous.
2. JSR (JavaScript Registry)
A new javascript package registry that competes with npm. It has a lot of advantages over npm which I won't go over here. But the interesting thing is you are only allowed to upload ES modules packages. No need for supporting the old standards.
Conclusion
The journey from global scope hacks to modern ES modules has transformed how we structure JavaScript. After years of experimenting with CommonJS, AMD, and UMD, ES modules have emerged as the clear standard, offering simpler syntax, better optimization, and improved performance.
While migrating to ES modules has been challenging, especially with Node.js support and ecosystem compatibility, the benefits are undeniable. With Node.js 23 improving interoperability and new tools like JSR promoting a unified module system, ES modules are set to become the default for JavaScript.
As we continue to embrace ES modules, we can look forward to cleaner, faster, and more maintainable code, marking a new era of modularity in JavaScript development.
Top comments (0)