ESM vs CommonJS

@vineetpjp|45,234 views

The module system debate in JavaScript has shaped the ecosystem for over a decade. Understanding the differences between ESM (ES Modules) and CommonJS isn't just academic — it affects how you write, bundle, and optimize your code.

The History

JavaScript didn't have a module system until ES6. Before that, Node.js adopted CommonJS, while browsers relied on script tags and global variables. This fragmentation led to tools like Browserify and RequireJS bridging the gap.

ES6 introduced native modules in 2015, but adoption was slow. Today, we're in a transition period where both systems coexist, sometimes awkwardly.

Syntax Differences

CommonJS uses require and module.exports:

// CommonJS
const express = require('express');
const { router } = require('./routes');

module.exports = {
  startServer() {
    // ...
  }
};

ESM uses import and export:

// ESM
import express from 'express';
import { router } from './routes.js';

export function startServer() {
  // ...
}

Static vs Dynamic

The fundamental difference is that ESM is static while CommonJS is dynamic. ESM imports must appear at the top level and can't be conditional:

// This works in CommonJS
if (condition) {
  const module = require('./module');
}

// This doesn't work in ESM
if (condition) {
  import module from './module'; // Syntax error!
}

// ESM alternative: dynamic import
if (condition) {
  const module = await import('./module');
}

This static nature enables tree shaking and better optimization, but requires different patterns for conditional loading.

Execution Timing

CommonJS loads modules synchronously. When you call require(), the module is loaded and executed immediately:

console.log('Before require');
const module = require('./module');
console.log('After require');

ESM imports are hoisted and evaluated before the module body runs. This can lead to subtle differences in execution order.

Top-Level Await

One of ESM's killer features is top-level await. You can use await at the module scope:

// ESM only
const data = await fetch('/api/config').then(r => r.json());

export const config = data;

This is impossible in CommonJS, where you'd need to export a Promise or use callbacks.

File Extensions and Package.json

Node.js determines module type based on file extension and package.json:

  • .mjs — Always ESM
  • .cjs — Always CommonJS
  • .js — Depends on "type": "module" in package.json

Interoperability

You can import CommonJS from ESM, but not vice versa (without dynamic import). This asymmetry creates challenges when migrating:

// ESM can import CommonJS
import express from 'express'; // express is CommonJS

// CommonJS can't import ESM (use dynamic import)
const module = await import('./esm-module.mjs');

The Future

ESM is the future. Modern bundlers, browsers, and Node.js all support it. The ecosystem is gradually migrating, with popular libraries releasing ESM versions alongside CommonJS.

If you're starting a new project, use ESM. If you're maintaining a library, consider dual packages. And if you're stuck with CommonJS, plan your migration path — the tooling is getting better every day.