The module system you choose impacts everything from bundle size to developer experience. Node.js supports both CommonJS and ES Modules, but the industry has shifted decisively toward ESM. This guide covers everything you need to make informed decisions about module systems in your projects.
The Evolution of JavaScript Modules
JavaScript was originally designed as a simple scripting language without built-in modularity. As applications grew more complex, developers created workarounds like the module pattern and namespaces to organize code. Eventually, two major module systems emerged:
- CommonJS for server-side JavaScript
- ECMAScript Modules as the official standard for browsers and servers
Understanding this evolution helps explain why both systems coexist today and why ESM is now the preferred choice for modern development.
According to the Better Stack guide on JavaScript modules, this historical context is essential for understanding modern module system decisions.
Understanding CommonJS
CommonJS was introduced in 2009 as a standardized module system for JavaScript outside the browser. Node.js adopted CommonJS as its default module system, making it the foundation of server-side JavaScript development for over a decade.
Key Characteristics
- Synchronous loading: Modules block execution until they finish loading
- require() for imports: Dynamic module loading at runtime
- module.exports for exports: Flexible export patterns
- Module caching: Modules are cached after first load
- No top-level await: Requires async wrapper functions
CommonJS Syntax
// Exporting
function addTwo(num) {
return num + 2;
}
module.exports = { addTwo };
// Importing
const { addTwo } = require('./addTwo.js');
console.log(addTwo(4)); // Prints: 6
CommonJS synchronous loading works well in server environments where file access is fast, but creates challenges for browser applications where asynchronous execution is the norm. As Better Stack explains in their CommonJS guide, this is why browser-based JavaScript needed a different approach.
Understanding ES Modules
ECMAScript Modules were introduced in ES6 (2015) as the official JavaScript module system. Unlike CommonJS, ESM is designed for both browsers and servers, providing a unified standard for JavaScript modularity.
Key Characteristics
- Asynchronous loading: Parallel module downloads
- Static import/export: Compile-time syntax for better optimization
- Tree shaking support: Bundlers can remove unused code
- Native browser support: No bundler required for modern browsers
- Top-level await: Use await outside async functions
ES Modules Syntax
// Exporting (addTwo.mjs)
export function addTwo(num) {
return num + 2;
}
// Importing
import { addTwo } from './addTwo.mjs';
console.log(addTwo(4)); // Prints: 6
To use ESM in Node.js, add "type": "module" to your package.json. Files with .mjs extension are also treated as ES modules regardless of package.json settings. As noted in the Better Stack ES Modules guide, this configuration is the foundation of modern Node.js development.
Tree Shaking
Bundlers eliminate unused exports, reducing bundle size
Native Browser Support
Run ESM directly in modern browsers without bundling
Static Analysis
Better tooling support for IDEs and linters
Top-Level Await
Simplify async module initialization
| Feature | CommonJS | ES Modules |
|---|---|---|
| Loading | Synchronous | Asynchronous |
| Syntax | require()/module.exports | import/export |
| Tree Shaking | No | Yes |
| Browser Support | Requires bundlers | Native support |
| Performance | Slower (blocking) | Faster (async + tree-shaking) |
| Top-level await | No | Yes (Node.js v14.8.0+) |
| __dirname/__filename | Available | Use import.meta.url |
| File Extensions | Optional | Required (.mjs, .js, .cjs) |
| JSON Imports | require('./file.json') | import with { type: 'json' } |
| Dynamic Imports | require() only | import() supported in both |
| Module Caching | require.cache | Separate cache system |
Synchronous vs Asynchronous Execution
One fundamental difference between CommonJS and ES Modules is how they handle module loading.
CommonJS: Synchronous Loading
CommonJS modules load synchronously, meaning each module blocks execution until it finishes loading. This approach:
- Works well for server-side Node.js where file access is fast
- Creates problems in browsers where network requests are asynchronous
- Makes module loading predictable but potentially slower
ES Modules: Asynchronous Loading
ES Modules use asynchronous loading, which:
- Enables parallel module downloads
- Aligns with how browsers handle resources
- Improves perceived performance through non-blocking behavior
The asynchronous nature of ESM is why it integrates so well with modern web development workflows and bundlers. As LogRocket explains in their module execution analysis, this difference is fundamental to understanding why ESM became the standard.
Tree Shaking and Bundle Optimization
Tree shaking is one of the most significant advantages of ES Modules over CommonJS.
What is Tree Shaking?
Tree shaking eliminates dead code by analyzing the static import graph and removing exports that are never used. This:
- Reduces final bundle size
- Improves page load times
- Reduces JavaScript parse and execution time
Why CommonJS Can't Tree Shake
CommonJS uses dynamic require() calls that are resolved at runtime. This means bundlers cannot determine which exports are actually used:
// CommonJS - bundler can't optimize
const utils = require('./utils');
// Which exports are used? Unknown until runtime.
ES Modules Enable Optimization
ESM's static import syntax allows precise analysis:
// ESM - bundler can tree shake
import { specificFunction } from './utils.js';
// Only specificFunction is included in the bundle
This is why modern frameworks like Next.js, Vite, and Astro default to ESM--it directly impacts application performance. According to Better Stack's analysis of ES Modules, tree shaking is one of the primary reasons to choose ESM for new projects.
Top-Level Await
ES Modules support top-level await, a feature that simplifies module initialization code.
CommonJS Limitation
In CommonJS, you cannot use await outside of an async function:
// CommonJS - await not allowed at top level
const data = await fetch('https://api.example.com/data');
// SyntaxError: Cannot use await outside async function
ESM Solution
ES Modules allow await at the top level:
// ESM - top-level await works
const data = await fetch('https://api.example.com/data');
console.log(await data.json());
This feature is especially useful for:
- Loading configuration files
- Initializing database connections
- Fetching environment variables
Top-level await has been available in Node.js since version 14.8.0. As documented in the Better Stack guide on ES Modules, this feature significantly simplifies asynchronous module patterns.
1// ESM - Module initialization with await2import { readFile } from 'node:fs/promises';3 4// Load configuration at module load time5const config = JSON.parse(6 await readFile(new URL('./config.json', import.meta.url))7);8 9// Use config immediately10export function getApiUrl() {11 return config.apiUrl;12}Best Practices for New Node.js Projects in 2025
For new projects, ES Modules should be the default choice. Modern frameworks like Next.js, Astro, and Vite are built around ESM. The ecosystem has largely migrated to ESM-only packages, reducing compatibility concerns.
Many modern web applications also integrate AI capabilities--explore our AI automation services to learn how intelligent systems can enhance your applications beyond traditional JavaScript patterns.
Recommended Setup
{
"name": "my-project",
"type": "module",
"version": "1.0.0",
"scripts": {
"start": "node src/index.js"
}
}
Guidelines for New Projects
- Add
"type": "module"to package.json - Opt into ESM mode - Use
.jsfile extensions - Required for ESM in Node.js - Use
import/exportsyntax - Modern, standardized approach - Leverage native ESM features - Top-level await, dynamic imports
- Configure bundlers for tree shaking - Optimize bundle size
- Prefer ESM-only packages - Many modern packages no longer support CommonJS
When CommonJS Still Makes Sense
- Maintaining legacy projects that haven't migrated
- Working with npm packages that are CommonJS-only
- CLI tools where synchronous loading is preferred
- Certain server-side scenarios requiring predictable execution order
For new projects in 2025, default to ESM and only use CommonJS when absolutely necessary for compatibility.
Migration Strategies from CommonJS to ESM
Many existing projects still use CommonJS. Migrating requires a strategic, incremental approach.
Incremental Migration
- Update package.json with
"type": "module" - Migrate files one at a time - Start with leaf modules
- Use extension-aware imports - Include
.jsor.mjsextensions - Test thoroughly - ESM has some behavioral differences
Dual Package Approach
Export from both CommonJS and ESM using conditional exports:
{
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
Interop Pattern
Create compatibility layers for mixed environments:
// index.js - Dual exports
export const myFunction = () => { /* ... */ };
// CommonJS compatibility
module.exports = { myFunction };
Common Migration Issues
- Circular dependencies: Behave differently in ESM
- __dirname/__filename: Use
import.meta.urlpattern - JSON imports: Use
with { type: 'json' }syntax - Dynamic requires: Replace with dynamic
import()
1. Add type: module
Update package.json to opt into ESM
2. Rename or convert files
Change .js files to ESM syntax or .mjs extension
3. Update imports
Add file extensions and use import syntax
4. Fix __dirname
Replace with import.meta.url pattern
5. Test thoroughly
Verify all modules load correctly
6. Update dependencies
Ensure packages support ESM
Performance Considerations
ES Modules typically offer better performance due to several factors:
Why ESM Is Faster
- Asynchronous loading enables parallel module downloads
- Tree shaking eliminates unused code from bundles
- Native browser support eliminates bundler overhead in some cases
- Better caching through content-based module identification
- Static analysis enables pre-bundling optimizations
Our web development services help teams implement ESM patterns that maximize these performance benefits for production applications.
When CommonJS May Be Preferable
Despite ESM's advantages, CommonJS remains viable in specific scenarios:
- CLI tools where synchronous loading provides predictable execution order
- Serverless functions with simple dependency graphs
- Legacy project maintenance where migration cost outweighs benefits
- Packages without ESM support that are essential to your project
Performance Comparison
| Scenario | CommonJS | ES Modules |
|---|---|---|
| Initial load (large app) | Slower (bundled) | Faster (parallel + tree shaking) |
| Caching efficiency | Good | Better (content-based) |
| Dynamic code splitting | Limited | Native support |
| Bundle size (with tree shaking) | Larger | Smaller |
For most modern web applications, ES Modules provide clear performance advantages. Both Better Stack and LogRocket confirm that ESM is the recommended choice for new projects targeting optimal performance.
Fast-loading pages also benefit your search engine rankings, as page speed is a known ranking factor for search visibility.
Frequently Asked Questions
Conclusion
The JavaScript ecosystem has evolved significantly, and ES Modules represent the clear future of JavaScript modularity. While CommonJS served the community well during Node.js's early years, modern web development benefits from ESM's:
- Standardized syntax across browsers and servers
- Better tooling support through static analysis
- Improved performance via tree shaking and parallel loading
- Native browser capabilities eliminating bundler requirements
Key Takeaways
- New projects should default to ES Modules with
"type": "module"in package.json - Migration from CommonJS should be a strategic priority for existing codebases
- Modern frameworks and tools are built around ESM, making it the natural choice
- Performance benefits of tree shaking and async loading are significant for production applications
The transition from CommonJS to ESM is one of the most impactful modernization steps you can take for your Node.js projects. Start by adopting ESM for new code and incrementally migrate existing modules to benefit from better performance, smaller bundles, and improved developer experience.