

The Case for Modules
JavaScript modules — something I never really understood, but always pretended I did. Different module systems like ESM and CommonJS made things even more confusing. The only thing I really knew was that ES modules are the modern standard. But why? What problems do module systems solve in the first place?
First of all, what even is a module?
Unless it’s a tiny side project, cramming all of our logic into one big JavaScript file doesn’t really work — for three key reasons:
- Readability
- Reusability
- Maintainability
We need some way to break down large codebases into more manageable parts.
Modules make this very easy.
Why not just use plain scripts?
That explains the need for modules… but wait, if the end goal is just to split large codebases into manageable parts, why not just create multiple JavaScript files and inject them as plain scripts?
That should work, right? So why do we need CommonJS or ES modules on top of that?
Let’s see what happens without modules… It’s doable, but there are some problems.
When we split code into different scripts that aren’t modules, the scripts don’t get isolated scopes. They share the same global scope.
const message = 'Hello, world!';
console.log(message);
<!DOCTYPE html><html lang="en"><head> <title>Demo</title></head><body> <script src="./message.js"></script> <script src="./main.js"></script></body></html>
Here, “Hello, world!” gets printed to the console, even though message
is declared in a completely separate script.
Everything is globally available to everyone. That doesn’t seem like a good idea…
Ideally, we’d want to develop in isolation and only share what we explicitly choose to share with other scripts.
We can “create” isolation using IIFEs (Immediately Invoked Function Expressions) to wrap each script’s contents.
(function () { const message = 'Hello, world!';})();
(function () { console.log(message);})();
This works as expected — trying to access message
outside its scope results in a ReferenceError
.
But now we have the opposite problem. Everything is private, but we’d still want to export certain things for reuse.
Without the ability to define exports like we do in modules, managing this is tricky. One way is to expose the values we want to export on the window
object (or global
object in Node.js).
(function () { const localMessage = 'Hello, world!'; window.message = localMessage;})();
(function () { console.log(window.message);})();
This might seem like a viable option, but it comes with several issues:
- How do we handle name collisions on the global object?
- How do we know who is exporting what?
More importantly, what happens if the load order of the scripts gets messed up?
<!DOCTYPE html><html lang="en"><head> <title>Demo</title></head><body> <script src="./main.js"></script> <script src="./message.js"></script></body></html>
This results in undefined
being printed to the console, because window.message
is not yet assigned when main.js
is executed.
It will be ready for use only after the browser finishes executing message.js
, which in this case happens at the very end.
In our simple example, we only used two scripts. Imagine thousands of files in a large codebase — managing dependencies will get very difficult, very fast.
ESM and friends to the rescue
Unsurprisingly, these were the exact challenges developers faced before JavaScript had a standardised module system.
Libraries used IIFEs and exposed values in shared namespaces. jQuery is a classic example — it exposed itself as window.$
.
Various custom module systems also emerged to fill that gap — CommonJS, AMD (RequireJS), and UMD to name a few.
Eventually, ES6 introduced native module syntax (import
, export
), and modern environments like browsers (via <script type="module">
) and Node started supporting ES modules out of the box.
Exploring how these different module systems work is a whole different blog in itself, but at the core of it they solve the same problem — managing code separation and dependencies.
← Back to blog