CommonJS vs ES Modules in Node.js — A Complete Guide
CommonJS vs ES Modules in Node.js explained: require vs import, differences in resolution, named vs default exports, interoperability patterns, and migration strategies..
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- ✓Basic Node.js and JavaScript knowledge. Familiarity with npm and Express.js is recommended.
Node.js supports two module systems: CommonJS (require/module.exports) and ES Modules (import/export). CommonJS is synchronous, loads modules at runtime, and has been the default since Node's inceptio
Imagine you're moving into a new house. CommonJS is like having all your boxes pre-labeled and stacked in the garage — you grab what you need, but everything is already there when you start. ES Modules are like having a smart home system: you tell the system 'I need the couch' and it fetches it from the warehouse only when you sit down. CommonJS loads everything upfront (synchronous), while ES Modules load on demand (asynchronous), making your app start faster and use less memory.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
The import statement looks cleaner. Everyone says ES Modules are the future. So you add 'type': 'module' to package.json, your Express app crashes with require is not defined, and you spend two hours debugging why __dirname is undefined. The reality is that Node.js runs two parallel module systems, and understanding the difference — not just the syntax — is essential for shipping production code in 2026. CommonJS is not going away, and ES Modules have sharp edges that even experienced engineers hit. This article covers the practical differences, not just the syntax.
Module Systems in Node.js: The Two Standards
Node.js supports two module systems: CommonJS (CJS) and ES Modules (ESM). CommonJS, the original system, uses and require()module.exports. ES Modules, the modern standard, uses import and export. Both coexist, but they differ fundamentally in syntax, behavior, and ecosystem support. Understanding these differences is critical for writing maintainable, performant Node.js applications. CommonJS is synchronous and loads modules at runtime, while ESM is asynchronous and supports static analysis, enabling tree-shaking and better optimization. The Node.js team recommends ESM for new projects, but CommonJS remains widespread in legacy code and npm packages.
Syntax and Export Differences
CommonJS uses module.exports to export a single value (often an object) and to import. ES Modules use named exports (require()export const) and default exports (export default), imported via import. ESM exports are live bindings — changes to the exported value in the exporting module are reflected in the importing module. CommonJS exports are copies. This distinction matters for mutable objects. Also, ESM supports top-level await, which CommonJS does not. The syntax differences affect how you structure your code and handle dependencies.
File Extensions and Package Type
Node.js determines the module system based on file extension and nearest package.json. Files with .mjs are always ESM; .cjs are always CommonJS; .js files default to CommonJS unless the nearest package.json has "type": "module". This setting applies to all .js files in the package. You can override per file with .mjs/.cjs. This flexibility allows gradual migration but can cause confusion when dependencies have mixed types.
"type": "module" in package.json while using ESM syntax will cause a SyntaxError.type settings. A shared utility was imported as CJS in one and ESM in another, causing duplicate instances and memory bloat..mjs for ESM, .cjs for CJS, or set "type": "module" in package.json for .js files.Interoperability: Importing CJS from ESM and Vice Versa
ESM can import CommonJS modules using the default import syntax: import pkg from 'cjs-pkg'. Named imports from CJS are also supported via a static analysis of the CJS module's exports. However, CJS cannot use import to load ESM — it must use dynamic , which returns a promise. This asymmetry is a common pain point. Also, ESM imports are static and must be at the top level, while import() is dynamic. When migrating a large CJS codebase to ESM, you'll often need to use dynamic imports for ESM modules inside CJS files.import()
import() in CJS to load ESM modules. It's asynchronous, so handle promises carefully.import() inside a CJS handler to load an ESM library. The async overhead caused cold starts to increase by 200ms.import().Circular Dependencies: How Each System Handles Them
Circular dependencies occur when module A imports module B, and B imports A (directly or indirectly). CommonJS handles this by returning a partial copy of the module's exports at the time of the require. This can lead to undefined values if the circular dependency is not carefully managed. ES Modules handle circular dependencies better because exports are live bindings — the importing module gets a reference to the exported value, which may still be undefined if the export hasn't been initialized yet. However, both systems can fail if not designed carefully. The best practice is to avoid circular dependencies altogether.
Performance and Tree-Shaking
ES Modules enable static analysis, which allows bundlers like Webpack and Rollup to perform tree-shaking — removing unused exports to reduce bundle size. CommonJS modules are dynamic, so tree-shaking is harder. In Node.js runtime, ESM has slightly higher startup overhead due to asynchronous loading, but this is negligible for most applications. For server-side code, the performance difference is minimal. However, for libraries published to npm, providing both CJS and ESM builds (dual packages) is common to support all consumers.
main (CJS) and module (ESM) fields in package.json to maximize compatibility.Migrating from CJS to ESM: A Practical Strategy
Migrating a large codebase from CommonJS to ES Modules requires careful planning. Start by setting "type": "module" in package.json and renaming files to .mjs incrementally. Use dynamic for CJS files that need to load ESM modules. Update all import() calls to require()import statements, and change module.exports to export default or named exports. Be aware of differences like __dirname and __filename — in ESM, use import.meta.url and fileURLToPath. Also, note that require.resolve is not available in ESM; use createRequire from module if needed. Test thoroughly, especially for circular dependencies and interop with third-party packages.
.cjs and .mjs files in the same project. Migrate one module at a time.require inside a try-catch that broke error handling. Use lint rules to catch remaining CJS syntax.__dirname replacement.Common Pitfalls and Best Practices
Common pitfalls include: forgetting to add "type": "module", using require in ESM (throws ReferenceError), assuming __dirname exists, and circular dependencies. Best practices: default to ESM for new projects, use named exports, avoid default exports for better tree-shaking, and always specify file extensions in imports (e.g., ./module.js not ./module). For dual-package libraries, use the exports field in package.json to map CJS and ESM entry points. Also, be careful with JSON imports — in ESM, you need to use assert { type: 'json' } (Node 17+) or createRequire.
createRequire to load JSON in ESM. In Node 17+, use import assertions.Tooling and Ecosystem Support
Modern tools like TypeScript, Webpack, Rollup, and Jest support both CJS and ESM. TypeScript can output both module types via module compiler option. Jest requires configuration to handle ESM (e.g., transform). ESLint's import plugin works with both. For testing, consider using Node's native test runner or Vitest for ESM-first projects. The npm registry hosts packages in both formats, but many popular packages still ship only CJS. Check the exports field in package.json to see what's available. The ecosystem is moving toward ESM, but CJS will remain for years.
"module": "ESNext" and "moduleResolution": "node" in tsconfig.json for ESM output.transform: {} and extensionsToTreatAsEsm: ['.ts']. Without it, tests failed with 'Unexpected token' errors.The Future: ESM as Default
Node.js is moving toward ESM as the default. Starting from Node 22, the --experimental-default-type flag may become stable. The community is pushing for ESM-first packages. However, CommonJS won't disappear soon due to legacy code. As a developer, you should be comfortable with both. For new projects, choose ESM. For existing CJS projects, consider migration if the benefits (tree-shaking, better static analysis, top-level await) outweigh the migration cost. The Node.js documentation recommends ESM for new projects.
Dynamic Imports: import() vs require()
While both and import() load modules dynamically, they differ fundamentally in behavior. require() is synchronous and returns the module exports directly. require() is asynchronous and returns a Promise, enabling lazy loading and code splitting. In Node.js, import() works in both CJS and ESM contexts, but import() is only available in CJS. Use require() for conditionally loading modules, especially when the module path is dynamic or when you need to load ESM from CJS. Note that import() always loads the module as ESM, even if the file is CJS, which can cause unexpected behavior if the module uses import()module.exports.
require() is not available unless you create it via module.createRequire(). In CJS files, import() works but returns a Promise. Avoid using both in the same file to prevent confusion.import() enables code splitting in bundlers like Webpack, reducing initial bundle size. Use it for route-level lazy loading in server-side apps.import() for dynamic, async loading; require() for synchronous, static loading in CJS.module.createRequire() — Bridging the Gap
When working in ESM, you lose access to require(). To load CJS modules dynamically, use module.createRequire(). This function creates a require function scoped to the current file's URL. It's essential for gradual migration from CJS to ESM, allowing you to keep legacy CJS dependencies while writing new code in ESM. However, avoid overusing it—it's a crutch, not a long-term solution. Prefer rewriting CJS modules to ESM when possible.
createRequire creates a new require function. Cache it at the top of your module to avoid overhead.createRequire can cause subtle issues if the required module has side effects. Prefer static imports for better optimization.module.createRequire() lets ESM files load CJS modules, but it's a migration tool, not a permanent pattern.import.meta — Module Metadata in ESM
ESM provides import.meta, an object containing metadata about the current module. The most common property is import.meta.url, which gives the file URL. This replaces __filename and __dirname from CJS. You can derive the directory path using fileURLToPath from the url module. import.meta is also extensible—Node.js adds properties like import.meta.resolve (experimental) for resolving module specifiers. Use import.meta for constructing paths relative to the current module, especially in ESM-only codebases.
__dirname or __filename. Always derive them from import.meta.url.import.meta.url for runtime path resolution if possible—prefer static paths or environment variables for better testability.import.meta.url is the ESM replacement for __filename; use fileURLToPath to convert it.Tree-Shaking Mechanics in ESM vs CJS
Tree-shaking—dead code elimination—works because ESM has static, analyzable imports and exports. Bundlers like Webpack and Rollup can determine which exports are used and remove unused ones. CJS, with its dynamic and require()module.exports, is not statically analyzable, making tree-shaking nearly impossible. For effective tree-shaking, always use named exports (export const foo) rather than default exports, and avoid side effects in modules. Mark your package as "sideEffects": false in package.json to allow aggressive tree-shaking.
"sideEffects": false to your package.json to tell bundlers your package has no side effects, enabling deeper tree-shaking.Package.json Exports Map and Conditional Exports
The exports field in package.json controls how other modules import your package. It replaces the traditional main field and allows subpath exports, conditional exports (based on environment), and preventing access to internal files. Conditional exports let you provide different entry points for different environments (e.g., Node.js vs browser, CJS vs ESM). This is crucial for dual-package hazards where you need to avoid loading the same module twice. Always define both "import" and "require" conditions to support both module systems.
require() and import() to ensure no duplicate instances or missing exports.exports map with conditional exports to support both CJS and ESM consumers and avoid dual-package hazards.Loader Hooks: Customizing Module Resolution
Node.js ESM loader hooks allow you to intercept and customize module resolution, loading, and transformation. The three main hooks are resolve (modify specifier resolution), load (transform source code), and transform (deprecated, use load instead). These hooks are powerful for transpilation (e.g., TypeScript), aliasing, or mocking. To use them, create a loader file and pass it via --experimental-loader flag. Note: this API is experimental and may change.
resolve, load) let you customize ESM module resolution and transformation, useful for transpilation and mocking.The Silent Undefined: A Circular Dependency Nightmare in CommonJS
require() executes. The function that was undefined was exported from module A but hadn't been assigned yet because module A was still executing when module B tried to use it.require() inside the function body instead of at the top level, but that would be a band-aid. The proper fix was to eliminate the cycle.- Circular dependencies are a design smell; avoid them whenever possible.
- CommonJS's handling of circular dependencies is fragile and can lead to hard-to-debug undefined values.
- Use tools like madge or dependency-cruiser to detect circular dependencies in CI.
- Consider migrating to ES Modules, which handle circular dependencies more gracefully via live bindings.
- When debugging undefined exports, always check for circular dependencies first.
| File | Command / Code | Purpose |
|---|---|---|
| cjs-example.js | const fs = require('fs'); | Module Systems in Node.js |
| esm-example.mjs | export const greet = (name) => `Hello, ${name}!`; | Syntax and Export Differences |
| package.json | { | File Extensions and Package Type |
| cjs-import-esm.cjs | async function loadESM() { | Interoperability |
| circular-cjs.js | const b = require('./b'); | Circular Dependencies |
| tree-shaking-example.js | export const used = () => 'used'; | Performance and Tree-Shaking |
| esm-dirname.mjs | const __filename = fileURLToPath(import.meta.url); | Migrating from CJS to ESM |
| esm-json-import.mjs | console.log(data); | Common Pitfalls and Best Practices |
| tsconfig.json | { | Tooling and Ecosystem Support |
| terminal | node --experimental-default-type=module index.js | The Future |
| dynamic-import.js | const { createRequire } = require('module'); | Dynamic Imports |
| create-require.js | const require = createRequire(import.meta.url); | module.createRequire() |
| import-meta.js | const __filename = fileURLToPath(import.meta.url); | import.meta |
| tree-shaking.js | export const used = () => 'used'; | Tree-Shaking Mechanics in ESM vs CJS |
| loader.mjs | export async function resolve(specifier, context, defaultResolve) { | Loader Hooks |
Key takeaways
require/module.exports (synchronous, runtime); ESM uses import/export (asynchronous, static). Choose ESM for new projects.import(). Be aware of the asymmetry when mixing systems."type": "module", renaming files, and updating imports. Replace __dirname with import.meta.url.import() for async, dynamic loading; require() is synchronous and only available in CJS. import() works in both systems but returns a Promise."sideEffects": false to enable aggressive dead code elimination.exports map with "import" and "require" conditions to support both module systems and avoid dual-package hazards.Interview Questions on This Topic
What is the key difference in how CommonJS and ES Modules handle module loading?
require() blocks until the module is fully loaded), while ES Modules use asynchronous, static loading (import is parsed before execution and loads dependencies in parallel). This makes ESM more suitable for browser environments and enables tree-shaking.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's Node.js. Mark it forged?
5 min read · try the examples if you haven't