Web Development

Choosing Between CommonJS and ESM: A Practical Guide to JavaScript Module Architecture

2026-05-02 16:32:01

Overview

Writing large-scale JavaScript applications without a module system is like building a skyscraper without blueprints—possible, but chaotic. In the early days, scripts were attached directly to the global scope, leading to frequent variable collisions and unpredictable behavior. The introduction of modules changed everything by providing private scopes and explicit public interfaces. But not all module systems are created equal. The two dominant systems—CommonJS (CJS) and ECMAScript Modules (ESM)—offer different trade-offs between runtime flexibility and static analyzability. This tutorial guides you through the nuances of each system, helping you make an informed architectural decision that will shape how your code is bundled, maintained, and executed.

Choosing Between CommonJS and ESM: A Practical Guide to JavaScript Module Architecture
Source: css-tricks.com

Modules are more than a way to split files; they define boundaries between components of your system. The choice between CJS and ESM is often your first architectural decision, influencing tooling, performance, and long-term maintainability.

Prerequisites

Before diving in, ensure you have the following:

If you’re new to Node.js, consider reviewing how require() works in older scripts as a starting point.

Step-by-Step Guide

1. Understand the Two Module Systems

CommonJS (CJS) was the first module system for JavaScript, designed primarily for server-side environments (Node.js). It uses require() to import modules and module.exports to export them. Because require() is a runtime function, it can be called conditionally, inside loops, or with dynamic paths.

// CommonJS - require() is a function, can appear anywhere
const fs = require('fs');

// Conditional import - valid CJS
if (process.env.DEBUG) {
  const debug = require('./debug');
}

// Dynamic path - also valid
const locale = require(`./locales/${lang}`);

ECMAScript Modules (ESM) are the official JavaScript module standard, introduced in ES6. They use import and export statements that must be at the top level and use static string specifiers.

// ESM - import is a declaration, must be at top
import { readFile } from 'fs';

// Invalid ESM - conditional import throws SyntaxError
if (process.env.DEBUG) {
  import { debug } from './debug'; // Error!
}

// Invalid ESM - dynamic path not allowed
import { locale } from `./locales/${lang}`; // Error!

The key difference: CJS prioritizes flexibility (you can import anywhere), while ESM prioritizes analyzability (static resolution).

2. Static Analysis and Tree-Shaking

Why does ESM enforce static imports? The answer is static analysis and tree-shaking. With CJS, because require() can hide dependencies behind conditions or variables, tools cannot reliably know which modules are actually needed until runtime. Bundlers like Webpack or Rollup then must include all possible modules, bloating the output.

ESM’s static structure allows tools to analyze the dependency graph without executing code. Unused exports can be safely removed (tree-shaking), leading to smaller bundles.

// CJS - bundler cannot determine if './productionLogger' is needed
const logger = process.env.NODE_ENV === 'production'
  ? require('./productionLogger')
  : require('./devLogger');
// Result: both modules are included in the bundle
// ESM - bundler can statically see which export is used
import { log } from './logger';
// If 'log' is the only used export, others are tree-shaken

This analyzability is a deliberate design trade-off: ESM sacrifices runtime flexibility to enable better optimization.

3. When to Use Each System

There is no one-size-fits-all answer. Consider these scenarios:

For new projects, lean toward ESM. For existing CJS codebases, gradual migration is feasible.

Choosing Between CommonJS and ESM: A Practical Guide to JavaScript Module Architecture
Source: css-tricks.com

4. Migrating from CJS to ESM

If you decide to switch, follow these steps:

  1. Add "type": "module" to your package.json to enable ESM at the project level (or use .mjs file extensions).
  2. Replace require() with import statements and module.exports with export.
  3. Update dynamic require() calls to use import() (dynamic import) – this is an ESM feature that returns a promise and works for async loading.
  4. Test thoroughly: some packages may not be ESM-compatible and require wrappers.
// Old CJS
const path = require('path');
module.exports = { myFunc };

// New ESM
import path from 'path';
export { myFunc };

Dynamic imports in ESM:

// ESM dynamic import (valid)
const module = await import(`./plugins/${pluginName}`);

Common Mistakes

Summary

Your choice of module system is a foundational architectural decision. CommonJS offers flexibility at runtime but hampers static analysis and tree-shaking. ECMAScript Modules enable better optimization and align with modern JavaScript standards, but impose strict syntax rules. For new projects, start with ESM; for legacy code, plan a gradual migration. Understand the trade-offs, test your tooling, and your codebase will remain maintainable as it grows.

Explore

7 Ways AI Is Transforming Database Management (Without Replacing Your DBA) GitHub Copilot Shifts to Consumption-Based Pricing: What You Need to Know Your Top Green Deals Questions Answered: Yozma Dirt Bike, EcoFlow Power Station, and More The Element-Data Credential Theft Incident: What You Need to Know Breaking: MTG's Smaug the Magnificent Unleashes Explosive Combo with D&D Card — Here's How