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

By • min read
<h2>Overview</h2><p>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—<strong>CommonJS</strong> (CJS) and <strong>ECMAScript Modules</strong> (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.</p><figure style="margin:20px 0"><img src="https://i0.wp.com/css-tricks.com/wp-content/uploads/2026/03/s_438F18945EAD505ECD4EDF4C4D7332DB9EE1178AECF38D5E1E1966514E384E9B_1772462582173_Untitled-scaled.png" alt="Choosing Between CommonJS and ESM: A Practical Guide to JavaScript Module Architecture" style="width:100%;height:auto;border-radius:8px" loading="lazy"><figcaption style="font-size:12px;color:#666;margin-top:5px">Source: css-tricks.com</figcaption></figure><p>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.</p><h2>Prerequisites</h2><p>Before diving in, ensure you have the following:</p><ul><li><strong>Basic JavaScript knowledge</strong> – familiarity with functions, objects, and scoping.</li><li><strong>Node.js installed</strong> – version 12 or later (to support both CJS and ESM).</li><li><strong>A package manager</strong> (npm or yarn) – to manage dependencies.</li><li><strong>A text editor</strong> – like VS Code or WebStorm.</li></ul><p>If you’re new to Node.js, consider reviewing how <code>require()</code> works in older scripts as a starting point.</p><h2>Step-by-Step Guide</h2><h3 id="step1">1. Understand the Two Module Systems</h3><p><strong>CommonJS (CJS)</strong> was the first module system for JavaScript, designed primarily for server-side environments (Node.js). It uses <code>require()</code> to import modules and <code>module.exports</code> to export them. Because <code>require()</code> is a runtime function, it can be called conditionally, inside loops, or with dynamic paths.</p><pre><code>// 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}`);</code></pre><p><strong>ECMAScript Modules (ESM)</strong> are the official JavaScript module standard, introduced in ES6. They use <code>import</code> and <code>export</code> statements that must be at the top level and use static string specifiers.</p><pre><code>// 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!</code></pre><p>The key difference: CJS prioritizes <em>flexibility</em> (you can import anywhere), while ESM prioritizes <em>analyzability</em> (static resolution).</p><h3 id="step2">2. Static Analysis and Tree-Shaking</h3><p>Why does ESM enforce static imports? The answer is <strong>static analysis</strong> and <strong>tree-shaking</strong>. With CJS, because <code>require()</code> 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.</p><p>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.</p><pre><code>// 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</code></pre><pre><code>// ESM - bundler can statically see which export is used import { log } from './logger'; // If 'log' is the only used export, others are tree-shaken</code></pre><p>This analyzability is a deliberate design trade-off: ESM sacrifices runtime flexibility to enable better optimization.</p><h3 id="step3">3. When to Use Each System</h3><p>There is no one-size-fits-all answer. Consider these scenarios:</p><ul><li><strong>Use CommonJS if</strong> 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.</li><li><strong>Use ESM if</strong> 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).</li><li><strong>Mixed usage</strong> is common: many projects use ESM for application code and CJS for certain dependencies. Tools like <code>esm</code> or <code>--experimental-modules</code> flags can help transition.</li></ul><p>For new projects, lean toward ESM. For existing CJS codebases, gradual migration is feasible.</p><figure style="margin:20px 0"><img src="https://i0.wp.com/css-tricks.com/wp-content/uploads/2026/03/s_438F18945EAD505ECD4EDF4C4D7332DB9EE1178AECF38D5E1E1966514E384E9B_1772462582173_Untitled-scaled.png?resize=2560%2C657&amp;#038;ssl=1" alt="Choosing Between CommonJS and ESM: A Practical Guide to JavaScript Module Architecture" style="width:100%;height:auto;border-radius:8px" loading="lazy"><figcaption style="font-size:12px;color:#666;margin-top:5px">Source: css-tricks.com</figcaption></figure><h3 id="step4">4. Migrating from CJS to ESM</h3><p>If you decide to switch, follow these steps:</p><ol><li>Add <code>&quot;type&quot;: &quot;module&quot;</code> to your <code>package.json</code> to enable ESM at the project level (or use <code>.mjs</code> file extensions).</li><li>Replace <code>require()</code> with <code>import</code> statements and <code>module.exports</code> with <code>export</code>.</li><li>Update dynamic <code>require()</code> calls to use <code>import()</code> (dynamic import) – this is an ESM feature that returns a promise and works for async loading.</li><li>Test thoroughly: some packages may not be ESM-compatible and require wrappers.</li></ol><pre><code>// Old CJS const path = require('path'); module.exports = { myFunc }; // New ESM import path from 'path'; export { myFunc };</code></pre><p>Dynamic imports in ESM:</p><pre><code>// ESM dynamic import (valid) const module = await import(`./plugins/${pluginName}`);</code></pre><h2>Common Mistakes</h2><ul><li><strong>Mixing <code>require</code> and <code>import</code> in the same file</strong> – unless using specific transpilers, ESM files cannot use <code>require()</code> and vice versa. Stick to one system per file.</li><li><strong>Forgetting to set <code>&quot;type&quot;: &quot;module&quot;</code></strong> – without it, Node.js treats <code>.js</code> files as CJS, causing syntax errors for ESM syntax.</li><li><strong>Using dynamic paths in static imports</strong> – remember ESM <code>import</code> requires static strings. Use <code>import()</code> for dynamic cases, but be aware it returns a promise.</li><li><strong>Assuming tree-shaking happens automatically</strong> – tree-shaking depends on your bundler’s configuration and the package’s side-effect flags. Not all CJS packages are tree-shakeable.</li><li><strong>Neglecting Node.js native module resolution</strong> – ESM in Node.js requires file extensions (<code>.js</code>, <code>.mjs</code>) for relative imports. Forgetting to add them causes “module not found” errors.</li></ul><h2>Summary</h2><p>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.</p>