Module Pattern in JavaScript: ES Modules vs CommonJS

TL;DR:
JavaScript’s two dominant module systems—ES Modules (import
/export
) and CommonJS (require
/module.exports
)—differ in syntax, loading strategy (static vs dynamic), tooling support, and ecosystem compatibility. Favor ES Modules for modern codebases and browsers, use CommonJS in legacy Node.js or when static analysis isn’t required, and adopt dual-publishing or transpilation strategies when migrating.
Why Module Patterns Matter
As your codebase grows, organizing functionality into discrete, reusable units helps:
- Maintainability: isolate features and dependencies
- Reusability: share code across files and projects
- Tree-shaking: eliminate unused code
- Encapsulation: hide internal implementation details
Without modules, globals proliferate, load order becomes fragile, and bundlers can’t optimize effectively.
1. ES Modules (ESM)
Syntax
// Named exports export function add(a, b) { return a + b } // Default export export default class User { /*…*/ } // Importing import { add } from './math.js' import User from './User.js'
Characteristics
- Static: imports are resolved at compile time
- Asynchronous: browser
<script type="module">
loads modules in parallel - Strict mode: modules run in strict mode by default
- Top-level
this
isundefined
Pros
- Enables tree-shaking in bundlers
- Aligns with modern ES spec and browsers
- Better for static analysis and IDE auto-completion
2. CommonJS (CJS)
Syntax
// Exporting function add(a, b) { return a + b } module.exports = { add } // Importing const { add } = require('./math.js')
Characteristics
- Dynamic:
require()
can be called anywhere at runtime - Synchronous: loads modules before execution
- Mutable exports: you can modify
exports
object - Compatible with Node.js out of the box
Pros
- Widely supported in Node.js ecosystem
- Simple dynamic loading for conditional imports
- No build step required in many cases
3. Key Differences
Feature | ES Modules | CommonJS |
---|---|---|
Syntax | import / export | require / module.exports |
Loading | Static, async in browser | Dynamic, sync |
Tree-shaking | ✅ | ❌ |
Default strict mode | ✅ | Depends |
Circular dependencies | Live bindings | Cached exports |
File extension requirement | .js or .mjs (depending) | .js |
4. Writing Modular Code
-
One export per file for clarity:
// src/utils/formatDate.js export function formatDate(date) { /*…*/ }
-
Index barrels to group related exports:
// src/components/index.js export { default as Button } from './Button.js' export { default as Modal } from './Modal.js'
-
Avoid deep relative paths by configuring aliases:
import { formatDate } from '@utils/formatDate'
5. Migrating Between Systems
-
Dual-publish packages:
// package.json { "main": "dist/index.cjs.js", "module": "dist/index.esm.js" }
-
Transpile ESM to CJS for legacy consumers (using Babel or TypeScript).
-
Use dynamic
import()
in CJS when needed:async function loadModule() { const { add } = await import('./math.js') console.log(add(1, 2)) }
6. Tooling & Bundler Support
- Node.js: since v13+, ESM is unflagged; use
.mjs
or set"type": "module"
. - Babel / SWC / TypeScript: transpile modules for older runtimes.
- Webpack / Rollup / Vite: all support ESM natively and optimize for tree-shaking.
- ESLint: enforce consistent module usage with rules like
import/no-commonjs
.
Best Practices & Anti-Patterns
Practice | ✅ Good Use | ❌ Anti-Pattern |
---|---|---|
Static imports for core modules | import fs from 'fs'; | const fs = require('fs'); |
Dynamic imports sparingly | if (cond) await import('./optional.js'); | require(path); with user input |
Single responsibility per file | One module = one concern | Huge files exporting dozens of things |
Barrel files for grouping | components/index.js | Deeply nested, unexported internals |
Explicit file extensions | import x from './utils.js'; | Omitting extensions in Node ESM |
Final Thoughts
- Prefer ESM for new projects to leverage modern features.
- Fallback to CJS when working with legacy Node.js or untranspiled scripts.
- Adopt dual-publishing to serve both ecosystems.
- Keep modules focused: clear, single-purpose exports, and index files for organization.
With these patterns, you’ll write cleaner, more maintainable JavaScript that works seamlessly across browsers and Node.js.