How JavaScript's Module Problem Built an Industry It's Only Now Dismantling
Source: lobsters
The binaryigor post on modern frontend complexity is worth reading because it asks the right question: how much of what we carry in a frontend project is load-bearing, and how much is scaffolding we forgot to remove. But to answer that question properly, you need to trace where the scaffolding came from. Most of what developers call frontend complexity in 2026 is the downstream consequence of a single original problem: JavaScript shipped without a module system, and the entire ecosystem spent twenty years improvising around that absence.
The Original Gap
Brendan Eich wrote JavaScript in ten days in 1995. It was designed for small scripts in a browser, not for applications. There was no concept of importing code from another file. If you wanted multiple scripts, you stacked <script> tags in your HTML, and all of them shared the same global scope. Name collisions were your problem.
For the first decade of web development this was tolerable, because the scripts were genuinely small. jQuery was a single file you included. Your own code was maybe a few hundred lines across two or three files. The global scope was messy, but it was manageable mess.
The AJAX era changed the calculus. Dynamic, stateful single-page applications became viable around 2005-2008, and suddenly people were writing thousands of lines of JavaScript. Global scope stopped being manageable and started being a source of real bugs. Developers began wrapping code in immediately-invoked function expressions (IIFEs) to create manual module boundaries:
(function() {
var privateVar = 'hidden from global scope';
window.MyLibrary = { doThing: function() { ... } };
})();
This worked. It also required every library author to invent their own convention for what to expose on window, and it provided no mechanism for declaring dependencies between files in a machine-readable way.
The Module Format Wars
Node.js shipped in 2009 with CommonJS as its module system. CommonJS gave you require() and module.exports, which was synchronous by design: Node was reading from disk, so synchronous I/O made sense. The format worked well for server code, and because Node was growing fast, npm became the package registry for JavaScript generally. This created a paradox: the largest package ecosystem in the world was built on a module format that did not work in browsers, because require() is synchronous and browsers load resources over networks.
The browser community proposed Asynchronous Module Definition (AMD) around 2010 as a browser-native alternative, implemented primarily through RequireJS. AMD used callbacks to handle the asynchronous loading:
define(['jquery', 'underscore'], function($, _) {
return {
doThing: function() { ... }
};
});
AMD and CommonJS coexisted uneasily for several years. Library authors who wanted to support both had to write Universal Module Definition wrappers, which is some of the most unpleasant boilerplate JavaScript has ever produced. A typical UMD header consumed ten or fifteen lines just to figure out what module system it was running in before it could do anything.
Webpack and the Bundler Era
Webpack entered the scene around 2012-2013 and cut through the format wars with a blunt answer: compile everything. Webpack could read CommonJS modules, transform them, and bundle them into a single file that ran in browsers. It understood AMD too. It could handle assets: images, CSS, fonts, all treated as modules that could be require()d.
The reason webpack became dominant is that it solved a real problem, but the solution came with a cost that compounded over time. Webpack’s configuration surface was enormous. A production configuration for a medium-sized application circa 2018 routinely exceeded 200 lines and required understanding the loader and plugin distinction, chunk splitting strategy, tree-shaking constraints, source map options, and a half-dozen other concerns that had nothing to do with the application being built. The webpack documentation today lists over 70 top-level configuration options.
The build graph that webpack managed was also genuinely complex. Circular dependencies could cause initialization order bugs that were nearly impossible to debug. Tree-shaking, the process of removing unused exports, required all modules to use static import/export syntax, which CommonJS does not have, so large portions of the npm ecosystem were immune to dead code elimination. A library that exported twenty utilities as named exports from a CommonJS file forced you to ship all twenty even if you used one.
ECMAScript Modules and the Long Wait
The TC39 committee finalized the ECMAScript module specification in ES2015. Native import and export syntax, statically analyzable at parse time, designed for asynchronous loading. This was the thing that should have existed in 1995.
import { debounce } from './utils.js';
export function processData(items) { ... }
Browsers began shipping ESM support in 2017-2018: Safari 10.1, Chrome 61, Firefox 60. But browser support alone was not enough to dismantle the bundler ecosystem, for several reasons.
First, the npm ecosystem was built on CommonJS. In 2019, roughly 95% of packages on npm used CommonJS. ESM-first packages were rare, and using a CommonJS package from an ESM context required bundler-specific handling. The dual-format package problem emerged: library authors began shipping both main (CommonJS) and module (ESM) fields in package.json, doubling their distribution surface. Node.js did not support native ESM until version 12 in 2019, and the interop between CJS and ESM in Node has been a source of confusion and edge cases ever since.
Second, performance at scale. Loading a large application as hundreds of individual ES module files over HTTP/1.1 was slower than loading a single bundled file, because of per-connection overhead and waterfall loading patterns. HTTP/2 multiplexing helped, but did not eliminate the problem at large file counts.
Third, toolchain inertia. By 2019, the webpack ecosystem was enormous. Thousands of loaders and plugins. Build configurations that teams had spent months tuning. Replacing that infrastructure required more than a better standard.
Vite and the Partial Resolution
Vite by Evan You shipped in 2020 and represented a genuine rethink. In development, Vite serves modules as native ESM directly, skipping the bundle step entirely. The browser handles module resolution, and Vite serves individual files with appropriate transforms. HMR is surgical: only the changed module and its dependents update. For a 2018 webpack project, rebuilds after a change could take several seconds. Vite’s HMR is typically under 100 milliseconds.
For production, Vite uses Rollup to bundle, because the performance argument for bundling still holds at scale. Rollup was designed from the start around ESM, and its tree-shaking is more thorough than webpack’s. The default Vite configuration for a new project is around 30 lines, against webpack’s hundreds.
Vite reduced accidental complexity meaningfully. But it did not eliminate the build step, and it introduced its own surface area: the Vite plugin API, the difference between development and production behavior, the dual-mode architecture where dev uses unbundled ESM and prod uses Rollup. These are smaller problems than webpack’s, but they are not zero problems.
Import Maps: The Platform Catching Up
Import maps reached full cross-browser support in 2023. They allow you to define module specifier mappings in HTML, so that bare specifiers like import { h } from 'preact' work in browsers without a build step:
<script type="importmap">
{
"imports": {
"preact": "https://cdn.jsdelivr.net/npm/preact@10/dist/preact.module.js",
"preact/hooks": "https://cdn.jsdelivr.net/npm/preact@10/dist/hooks.module.js"
}
}
</script>
For applications that can tolerate per-file HTTP requests rather than bundles, import maps make a build step optional in a way that was not true before 2023. The esm.sh CDN transforms CommonJS packages to ESM on the fly, which means even packages not distributed as ESM are accessible without a local bundler.
This is not a practical production setup for large applications: HTTP/2 does not fully close the latency gap for hundreds of modules, CDN-sourced dependencies have availability and caching tradeoffs, and tree-shaking across separately loaded files is not possible. But for small tools, internal dashboards, and developer utilities, it is a real option that did not exist five years ago.
Where the Complexity Actually Lives Now
The module system problem has been substantially resolved. ESM is standard. The npm ecosystem is steadily adding ESM distributions; packages like lodash-es exist specifically to provide tree-shakeable alternatives to CommonJS incumbents. Node.js 22 supports ESM natively. Vite and the bundlers that use Rollup under the hood handle the remaining interop cases.
What remains is structural, not platform-level. TypeScript compilation. JSX transformation. CSS modules or CSS-in-JS. These tools exist because they add genuine value: TypeScript catches real bugs, JSX is considerably more readable than React.createElement calls, CSS modules prevent the naming collisions that global CSS creates in large codebases. They are not going away, and they should not.
The complexity that was genuinely accidental, the part that came from improvising around a missing language feature for twenty years, is smaller than it was. The build step is lighter. The configuration surface is smaller. The format wars are over. What is left is mostly the complexity of solving the actual problems that frontend development presents: component composition, state synchronization, server-client coordination, accessibility, and performance.
That complexity is harder to blame on tooling decisions. It is closer to what Brooks would call essential. You can minimize the toolchain, and you should where the application supports it. But the irreducible core of building a large interactive UI was always going to be substantial, and recognizing that makes it easier to make pragmatic decisions about which tools actually earn their place.
The lesson from tracing this history is not that the ecosystem made bad decisions. Given the constraints that existed in 2012 or 2015, webpack and Babel and CommonJS were reasonable responses to real problems. The lesson is that some of the complexity developers treat as inherent to frontend work is actually historical residue, and knowing the difference lets you evaluate current tools on their actual merits rather than their inherited necessity.