JavaScript Module Systems: A Comprehensive Guide to CJS and ESM

From Eatncure, the free encyclopedia of technology

Overview

Building large-scale JavaScript applications without a proper module system is like constructing a skyscraper with no blueprints—chaotic and error-prone. Before the advent of modules, developers relied solely on the global scope, leading to variable collisions, overwriting scripts, and unmanageable codebases. JavaScript modules solve this by introducing private scopes and explicit public interfaces, but they also introduce a critical architectural decision: which module system to adopt?

JavaScript Module Systems: A Comprehensive Guide to CJS and ESM
Source: css-tricks.com

This guide dives deep into the two dominant JavaScript module systems—CommonJS (CJS) and ECMAScript Modules (ESM). You’ll learn their syntax, flexibility trade-offs, and how they impact static analyzability and tree-shaking. By the end, you’ll know how to choose the right system for your project and avoid common pitfalls.

Prerequisites

Basic JavaScript Knowledge

Familiarity with JavaScript syntax, functions, and objects is assumed. No prior experience with module systems is required, but understanding scope and closures will help.

Node.js Environment (Optional but Recommended)

To test examples, have Node.js (v12 or later for ESM support) installed. A code editor and terminal are all you need.

Step-by-Step Guide

1. Understanding CommonJS (CJS)

CommonJS was the first JavaScript module system, designed primarily for server-side environments like Node.js. Its syntax is function-based, using require() to import and module.exports to export.

Basic CJS Example:

// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
module.exports = { add, subtract };

// app.js
const math = require('./math');
console.log(math.add(2, 3)); // 5

The require() function is dynamic—it can appear anywhere in your code:

// Conditional require - valid in CJS
if (process.env.NODE_ENV === 'production') {
  const logger = require('./productionLogger');
  logger.enable();
}

Dynamic paths are also possible:

const plugin = require(`./plugins/${pluginName}`);

This flexibility makes CJS convenient for runtime decisions, but it comes at a cost: static analysis tools (like bundlers) cannot determine dependencies without executing code. They must include all potential modules, which bloats bundles.

2. Understanding ECMAScript Modules (ESM)

ESM is the official JavaScript module standard, introduced in ES2015. It uses static import and export declarations that must appear at the top of a module.

Basic ESM Example:

// math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

// app.js
import { add, subtract } from './math.js';
console.log(add(2, 3)); // 5

ESM imposes strict rules: imports must be top-level, paths must be static strings (no variables or template literals).

// Invalid - conditional import
if (condition) {
  import { helper } from './helper.js'; // SyntaxError
}

These constraints enable static analysis: bundlers can parse imports at build time, identify unused exports, and eliminate them via tree-shaking. This results in smaller bundles—critical for web performance.

3. Comparing Flexibility vs. Analyzability

The trade-off is clear:

  • CommonJS offers runtime flexibility (dynamic requires, conditional loading) but resists static analysis.
  • ESM sacrifices flexibility for analyzability, enabling optimizations like tree-shaking and dead-code elimination.

Modern bundlers (Webpack, Rollup) handle both, but they work best with ESM. For example, Rollup can statically analyze ESM imports and remove unused modules, but with CJS it must fall back to heuristic or include everything.

JavaScript Module Systems: A Comprehensive Guide to CJS and ESM
Source: css-tricks.com

When to use each?

  • Choose ESM for new projects, especially web apps where bundle size matters.
  • Use CommonJS for legacy Node.js packages, or when you need dynamic loading (e.g., plugins, configuration-based requires).

4. Choosing Your Module System

Factors to consider:

  • Target environment: Browsers natively support ESM (since 2017). Node.js supports both, but ESM is the future.
  • Dependencies: If your project relies on many CJS packages, mixing systems can cause issues. Use "type": "module" in package.json to enable ESM in Node.js.
  • Tooling: Bundlers like Webpack allow mixed usage but prefer ESM for optimal output.

Common Mistakes

Avoid these pitfalls when working with JavaScript modules:

  • Mixing CJS and ESM incorrectly: In Node.js, you cannot use require() in an ESM module unless you wrap it in a createRequire utility. This leads to runtime errors.
  • Forgetting file extensions: ESM requires explicit extensions (./module.js). CJS often omits them, causing confusion when switching.
  • Using dynamic paths in ESM: This will throw a SyntaxError. For dynamic loading in ESM, use import() (dynamic import) which returns a promise—still analyzable to some extent.
  • Not understanding tree-shaking: Side effects in modules (e.g., polyfills that modify globals) can prevent tree-shaking. Mark side-effect-free packages in package.json.

Summary

Your choice between CommonJS and ESM is more than a syntactic preference—it’s an architectural decision affecting maintainability, performance, and tooling. CommonJS provides runtime flexibility but hampers static analysis. ESM, though stricter, unlocks powerful optimizations like tree-shaking. For most new projects, especially those targeting browsers, prefer ESM. For Node.js backends, consider the ecosystem and need for dynamic loading. Armed with this guide, you can now design a module system that scales.