ES Modules vs CommonJS: How JavaScript Imports Work in Node.js, Tested
JavaScript has two module systems: the older CommonJS with require, and the standard ES modules with import and export. They behave differently in ways that cause real bugs. We ran both side by side in Node.js to show exactly how.
- ES modules (
import/export) are the JavaScript standard; CommonJS (require/module.exports) is Node.js’s original system. - In Node.js, a file is ESM if it ends in
.mjsor itspackage.jsonhas"type": "module". - ESM imports are live, read-only bindings; CommonJS gives you a copy of whatever was exported.
- Both systems run each module once and cache it. ESM requires full file extensions in relative imports.
You copy a code sample that starts with import into a Node.js project and get “Cannot use import statement outside a module”. Or you switch a project to ES modules and suddenly __dirname is undefined. These errors come from the two module systems JavaScript lives with. Understanding ES modules vs CommonJS turns them from mysteries into one-line fixes.
We built a small project and ran it with Node.js 24. All outputs below are from that run.
The short answer
| CommonJS | ES modules | |
|---|---|---|
| Import | const x = require("./x") |
import x from "./x.js" |
| Export | module.exports = ... |
export, export default |
| Loading | Synchronous, at runtime | Static structure, loaded before running |
| Imported values | A copy of the exported value | Live, read-only bindings |
__dirname |
Available | Not available; use import.meta |
Top-level await |
No | Yes |
| Browsers | Needs a bundler | Native, <script type="module"> |
Why modules exist
Without modules, every script shares one global scope, and variables from different files collide. Modules give each file its own scope and an explicit list of what it shares. Node.js adopted CommonJS in 2009, long before the language had a standard. ES modules arrived in ECMAScript 2015 (ES6) and are now supported natively by browsers and Node.js. Most new code and many popular packages are ESM, but huge amounts of existing code and npm packages are still CommonJS, so you will meet both.
CommonJS basics
// legacy.cjs
module.exports = { hello: () => "hi from CommonJS", n: 1 };
// user.cjs
const legacy = require("./legacy.cjs");
console.log(legacy.hello());
require is an ordinary function. It runs when the line executes, can appear anywhere, even inside an if, and returns whatever the other file assigned to module.exports. Each CommonJS file also gets __dirname and __filename for free; our test printed typeof __dirname as “string”.
ES module basics
// lib/math.js
export const TAX = 0.18;
export function addTax(x) { return Math.round(x * (1 + TAX) * 100) / 100; }
export default function format(x) { return `Rs ${x.toFixed(2)}`; }
// main.js
import format, { addTax, TAX as RATE } from "./lib/math.js";
import * as math from "./lib/math.js";
console.log(format(addTax(1000)), RATE, typeof math.default);
// Rs 1180.00 0.18 function
A module can have many named exports and at most one default export. Named imports use braces and can be renamed with as; import * as math collects everything into one namespace object. Import statements look a little like destructuring, but they are a separate syntax with their own rules.
Turning on ES modules in Node.js
Node.js decides how to treat each file:
.mjsfiles are always ES modules;.cjsfiles are always CommonJS..jsfiles follow the nearestpackage.json:"type": "module"makes them ESM, otherwise they are CommonJS.
Our project used a package.json containing just { "type": "module" }. Recent Node.js versions can also detect ESM syntax in ambiguous files, but relying on explicit configuration is clearer for everyone reading the project.
Live bindings: the biggest behavioural difference
// lib/math.js
export let counter = 0;
export function increment() { counter++; }
// main.js
import { counter, increment } from "./lib/math.js";
console.log(counter); // 0
increment();
console.log(counter); // 1
counter = 5;
// TypeError: Assignment to constant variable.
An ES import is a live view of the exporting module’s variable. When the module changed counter, our import saw the new value. Imports are also read-only from the outside: assigning to one threw a TypeError. In CommonJS, require returns the exported object, so primitive values are copied at the moment of export, and the importing file can modify the shared object, which surprises people in the opposite way.
Modules run once and are cached
Our math.js printed “[math.js evaluated]” when it loaded. Although main.js imported it three times, with a default import, a namespace import and a renamed import, the message appeared only once, and again === addTax was true. CommonJS behaves the same way:
const legacy = require("./legacy.cjs");
legacy.n = 2;
const again = require("./legacy.cjs");
console.log(again === legacy, again.n); // true 2
Because modules are singletons, top-level code in a module runs once per process. That is useful for setting up a shared database pool, but dangerous for shared mutable state in tests, where one test’s changes leak into the next.
Static vs dynamic
ESM import statements must appear at the top level of the file, not inside functions or conditions. Because the structure is known before any code runs, tools can see exactly what is used, which enables tree shaking: bundlers drop unused exports from the final bundle. When you do need to load something conditionally or lazily, both systems support dynamic import(), which returns a promise:
const { addTax: lazy } = await import("./lib/math.js");
console.log(lazy(100)); // 118
Top-level await
The example above used await outside any function, which only works in ES modules. It is convenient for loading configuration or connecting to a service at startup. Use it sparingly in libraries: a module that awaits at the top level delays every module that imports it.
Paths, extensions and __dirname
await import("./lib/math"); // ERR_MODULE_NOT_FOUND
await import("./lib/math.js"); // works
console.log(typeof __dirname); // undefined in ESM
console.log(import.meta.url); // file:///.../esm/main.js
Node.js ES modules require the full file extension in relative imports; CommonJS would have guessed it. This is the most common error when converting a project. Instead of __dirname, ES modules have import.meta.url, and recent Node.js versions also provide import.meta.dirname and import.meta.filename.
Tip: When moving a project to ESM, convert in this order: set "type": "module", add file extensions to every relative import, replace __dirname, then fix remaining require calls. Run your test suite after each step rather than at the end.
Mixing ESM and CommonJS
// ESM importing CommonJS
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const cjs = require("../cjs/legacy.cjs");
console.log(cjs.hello()); // hi from CommonJS
const cjs2 = await import("../cjs/legacy.cjs");
console.log(typeof cjs2.default.hello); // function
// CommonJS importing ESM
import("../esm/lib/math.js").then(m => console.log(m.addTax(100))); // 118
ESM can import CommonJS directly; the module.exports object arrives as the default export. CommonJS can always load ESM with dynamic import(). Recent Node.js versions also allow require() of ES modules that do not use top-level await; check your Node.js version before relying on it. Library authors who must support both often publish “dual packages” using the exports field in package.json, which needs care to avoid loading two copies of the same module.
ES modules in the browser
Browsers load ES modules natively with <script type="module" src="main.js"></script>. Module scripts are deferred automatically, run in strict mode and have their own scope. Import paths must be full URLs or relative paths; bare names like "lodash" need an import map or a bundler. CommonJS does not run in browsers without a bundler. We did not run browser examples for this article.
Testing considerations
Test runners differ in how well they support ESM, and mocking is harder because ESM imports are read-only bindings. Vitest supports ESM natively; Jest’s ESM support requires extra configuration. Design for testability by passing dependencies into functions rather than reaching for module-level singletons, the same advice behind good mocking in any language.
Which should you use?
- New projects: ES modules. They are the standard and work in Node.js, browsers, Deno and Bun.
- Existing CommonJS apps: no need to rewrite urgently; migrate gradually when you touch the code or a dependency requires it.
- Libraries: ESM first, adding CommonJS output only if many users still need it.
- TypeScript: write ESM syntax, and set
moduleandmoduleResolutionto match your runtime, such as “NodeNext”; see our TypeScript tutorial.
Final thoughts
ES modules and CommonJS solve the same problem with different rules. ESM gives static structure, live read-only bindings, top-level await and browser support; CommonJS offers simple, synchronous require calls and convenient globals. Use ESM for new work, add full file extensions, replace __dirname with import.meta, and use dynamic import() or createRequire where the two worlds meet.
Frequently asked questions
What is the difference between ES modules and CommonJS?
ES modules use import and export, have a static structure and live bindings, and work in browsers. CommonJS uses require and module.exports, loads synchronously at runtime, and is Node.js’s original system.
How do I use import in Node.js?
Use the .mjs extension, or add “type”: “module” to package.json. Then use full file extensions in relative import paths.
Why is __dirname not defined in ES modules?
ES modules do not provide CommonJS globals. Use import.meta.url, or import.meta.dirname in recent Node.js versions.
Can I use require in an ES module?
Not directly. Create one with createRequire(import.meta.url) from node:module, or use dynamic import() instead.
Does importing a module twice run it twice?
No. In both systems a module runs once and is cached. In our test, a module imported three ways printed its load message only once.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.