Choosing Between CommonJS and ESM: A Practical Guide to JavaScript Module Architecture
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.

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:
- Basic JavaScript knowledge – familiarity with functions, objects, and scoping.
- Node.js installed – version 12 or later (to support both CJS and ESM).
- A package manager (npm or yarn) – to manage dependencies.
- A text editor – like VS Code or WebStorm.
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-shakenThis 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:
- Use CommonJS if you need dynamic imports (e.g., loading plugins based on configuration), are working in a legacy Node.js project, or rely on packages that still export CJS.
- Use ESM if you write new front-end or server-side code, want tree-shaking, or prefer the standard syntax. Modern bundlers and Node.js support ESM natively (since v12).
- Mixed usage is common: many projects use ESM for application code and CJS for certain dependencies. Tools like
esmor--experimental-modulesflags can help transition.
For new projects, lean toward ESM. For existing CJS codebases, gradual migration is feasible.

4. Migrating from CJS to ESM
If you decide to switch, follow these steps:
- Add
"type": "module"to yourpackage.jsonto enable ESM at the project level (or use.mjsfile extensions). - Replace
require()withimportstatements andmodule.exportswithexport. - Update dynamic
require()calls to useimport()(dynamic import) – this is an ESM feature that returns a promise and works for async loading. - 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
- Mixing
requireandimportin the same file – unless using specific transpilers, ESM files cannot userequire()and vice versa. Stick to one system per file. - Forgetting to set
"type": "module"– without it, Node.js treats.jsfiles as CJS, causing syntax errors for ESM syntax. - Using dynamic paths in static imports – remember ESM
importrequires static strings. Useimport()for dynamic cases, but be aware it returns a promise. - Assuming tree-shaking happens automatically – tree-shaking depends on your bundler’s configuration and the package’s side-effect flags. Not all CJS packages are tree-shakeable.
- Neglecting Node.js native module resolution – ESM in Node.js requires file extensions (
.js,.mjs) for relative imports. Forgetting to add them causes “module not found” errors.
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.
Related Discussions