The ES Modules Hangover: Why We Can’t Go Back
7 mins read

The ES Modules Hangover: Why We Can’t Go Back

I still wake up in a cold sweat sometimes thinking about AMD. You remember Asynchronous Module Definition? That define([], function(){}) boilerplate that made your files look like they were nested five levels deep before you wrote a single line of logic? Yeah, that.

We’ve spent the last decade fighting a war over how to get one JavaScript file to talk to another. First it was script tags and global variables (the wild west). Then CommonJS came along and saved us on the server, but broke us in the browser. Then Webpack arrived to stitch it all back together, but it took ten seconds to rebuild a Hello World app.

Now, sitting here at the tail end of 2025, things are… quiet. Suspiciously quiet.

ES Modules (ESM) won. They’re the standard. Browsers support them natively. Node.js supports them (mostly). The tooling ecosystem has completely reshaped itself around them. But if you think that means everything is perfect, you haven’t looked at your node_modules folder lately.

The “Unbundled” Dream vs. Reality

I remember when the first wave of ESM-native build tools started gaining traction a few years back. The pitch was seductive: “No bundling in development.”

Instead of smashing your entire application into a massive bundle.js every time you hit save, the dev server would just serve the file you changed. The browser would see import { value } from './module.js' and just… go fetch it. HTTP requests are cheap on localhost. It was brilliant.

It fundamentally changed how I work. Instant server starts. Hot Module Replacement (HMR) that actually felt hot, not lukewarm.

But here’s the thing nobody likes to talk about: the browser is terrible at resolving dependency trees. If you import a library like Lodash the naive way, and it triggers 600 internal requests, your browser is going to choke. Chrome is fast, but it’s not “600 network requests in 100ms” fast.

So we ended up in this weird middle ground we’re still navigating today. We don’t bundle our code during dev, but we pre-bundle our dependencies using tools like esbuild. It’s a hack, technically. But it’s a hack that works so well we stopped caring.

Static Analysis is the Real MVP

Code dependency graph - Safely restructure your codebase with Dependency Graphs
Code dependency graph – Safely restructure your codebase with Dependency Graphs

The biggest win with ESM isn’t the syntax. I don’t care if I type require or import. The win is that ESM is static.

With CommonJS, you could do unholy things like this:

// Please don't do this
const moduleName = isTuesday ? './tuesday.js' : './wednesday.js';
const myModule = require(moduleName);

Great for flexibility. Terrible for tools. A bundler looks at that and goes, “Well, I guess I have to include everything, just in case.”

ES Modules force imports to be at the top level (mostly—we’ll get to dynamic imports in a second). Because the structure is static, tools can build a dependency graph without executing a single line of code. This is why tree-shaking actually works now. It’s why your IDE knows exactly what methods are available before you run the code.

And honestly? It’s helping the AI coding assistants we’re all addicted to these days. An LLM can parse a static import graph much more reliably than it can guess the runtime execution path of a spaghetti-code require chain.

The Node.js “Dual Package” Headache

If you write frontend code, you’re probably fine. You use a build tool, it spits out what the browser needs, you go home.

But if you’re a library maintainer? I’m sorry.

The transition period has been agonizingly long. We still have to ship both CommonJS (for the laggards) and ESM (for the modern web) in the same package. You end up with package.json files that look like tax returns.

{
  "name": "my-lib",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

And don’t get me started on the file extensions. .mjs? .cjs? It feels like we’re back in the Windows 98 days worrying about three-letter extensions. I’ve wasted days of my life debugging ERR_REQUIRE_ESM errors because some deep dependency decided to go “pure ESM” while my test runner was stuck in CommonJS land.

My advice? Rip the band-aid off. In my new projects, I just set "type": "module" in package.json and refuse to look back. If a library doesn’t support ESM in 2025, I find a different library. Life is too short.

Dynamic Imports: The Escape Hatch

Remember how I said static imports were the best thing ever? Sometimes they’re too rigid. Sometimes you really do want to load a heavy chart library only when the user clicks the “Analytics” tab.

This is where import() (the function) shines. It returns a Promise. It’s the best of both worlds: static analysis where you need stability, and dynamic loading where you need performance.

// This button handler is tiny...
button.addEventListener('click', async () => {
  // ...until you click it.
  const { heavyChart } = await import('./heavy-chart.js');
  heavyChart.render();
});

Most modern frameworks handle this automatically now. You define a route, they code-split it using dynamic imports under the hood. You don’t even have to think about it. That’s the goal of good tooling: making complex spec features boring.

Top-Level Await: The Feature We Deserved

One final victory lap for ESM: Top-level await. For years, we had to wrap our startup logic in immediately invoked async functions (IIAFE? Is that a thing?).

(async () => { await db.connect(); })();

Ugly. With ESM, modules are async by default if they need to be. You can just write:

// db.js
const connection = await connectToDatabase();
export default connection;

The module system pauses execution of any module importing this one until the promise resolves. It simplifies database connections, config loading, and WASM initialization so much it hurts to remember how we used to do it.

So, Are We There Yet?

Mostly. The browser support is solid. The tooling is mature. The only friction left is the legacy drag of the npm ecosystem, which is slowly shedding its CommonJS skin.

We traded the complexity of configuration (Webpack configs that were 500 lines long) for the complexity of ecosystem compatibility. I think it was a fair trade. I can spin up a dev server in 300 milliseconds now. Ten years ago, I had time to go make coffee while my app built.

I’ll take the ERR_REQUIRE_ESM errors if it means I never have to see define(['jquery'], function($)... ever again.

Leave a Reply

Your email address will not be published. Required fields are marked *