Michael Ouroumis logoichael Ouroumis

Module Pattern in JavaScript: ES Modules vs CommonJS

Flat-style illustration of interlocking puzzle pieces labeled “import” and “require” to represent ES Modules and 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 is undefined

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

FeatureES ModulesCommonJS
Syntaximport / exportrequire / module.exports
LoadingStatic, async in browserDynamic, sync
Tree-shaking
Default strict modeDepends
Circular dependenciesLive bindingsCached 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

  1. Dual-publish packages:

    // package.json { "main": "dist/index.cjs.js", "module": "dist/index.esm.js" }
  2. Transpile ESM to CJS for legacy consumers (using Babel or TypeScript).

  3. 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 modulesimport fs from 'fs';const fs = require('fs');
Dynamic imports sparinglyif (cond) await import('./optional.js');require(path); with user input
Single responsibility per fileOne module = one concernHuge files exporting dozens of things
Barrel files for groupingcomponents/index.jsDeeply nested, unexported internals
Explicit file extensionsimport 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.

Enjoyed this post? Share it: