Sitemap
CodeToDeploy

Tech Insights | Careers | Jobs

The Module System: ES Modules vs CommonJS

8 min readJul 15, 2026

--

Press enter or click to view image in full size
image

You copy a code snippet from npm’s README. You paste it into your project. Node throws a cryptic error: SyntaxError: Cannot use import statement in a module.

💥 Master Any Skills in 3 Months
📚 Up to 50% OFF Premium Courses
⏰ Limited-Time Offer
👉 Enroll Now & Start Learning

Press enter or click to view image in full size

So you change import to require. Now a different error: ReferenceError: require is not defined.

You’ve probably been there. The JavaScript module system has been a source of confusion for years — not because it’s fundamentally hard, but because there are two systems coexisting in the ecosystem today, each with its own rules, behaviors, and quirks. Understanding the difference between ESModules (ESM) and CommonJS (CJS) isn’t just trivia. It directly affects how your code runs, how it gets bundled, and how libraries you depend on behave in production.

What Is a Module System, Anyway?

Before JavaScript had any official module system, developers used patterns like IIFEs (Immediately Invoked Function Expressions) or globals to share code across files. It was messy.

Node.js introduced CommonJS in 2009 to solve this for server-side code. It gave us require() and module.exports — a synchronous, file-based way to share code.

Then, in 2015, ECMAScript 2015 (ES6) introduced ES Modules — a native module system built into the JavaScript language specification itself. This gave us import and export syntax, designed to work in both browsers and Node.js.

Both systems do the same conceptual job: they let you split your code into reusable pieces. But they work very differently under the hood.

CommonJS: The Original Node.js Way

CommonJS was designed with one thing in mind — server-side JavaScript. It loads modules synchronously, which makes sense on a server where all files are local and reading them is fast.

The Basic Syntax

// math.js
function add(a, b) {
return a + b;
}

// Export the function
module.exports = { add };
// app.js
// Import using require()
const { add } = require('./math');

console.log(add(2, 3)); // 5

require() is a function call — it runs at runtime, reads the file, executes it, and returns whatever module.exports holds.

What Happens Behind the Scenes

When Node.js encounters require('./math'), it:

  1. Resolves the file path
  2. Checks if the module is already in the cache (if yes, returns the cached exports — doesn’t re-execute)
  3. If not cached, reads the file, wraps it in a function, executes it, and stores the result in the cache

That caching behavior is important. Two different files can both require('./math') and they'll get the same object back. This can cause subtle bugs if you're not careful about mutating shared module state.

ES Modules: The Native JavaScript Standard

ESModules are defined in the ECMAScript specification and are now supported natively in browsers and Node.js (since v12, stable in v14+).

Unlike CJS, ESM is designed to be statically analyzable. The imports and exports are declared at the top of the file and resolved before the code runs — not during execution.

The Basic Syntax

// math.js
export function add(a, b) {
return a + b;
}
// app.js
import { add } from './math.js';

console.log(add(2, 3)); // 5

Notice the .js extension in the import path — in ESM, file extensions are required when importing local files in Node.js. This is a common trip wire.

Named vs Default Exports

ESM supports two types of exports:

// Named exports — you can have many
export const PI = 3.14159;
export function multiply(a, b) { return a * b; }

// Default export - only one per module
export default function greet(name) {
return `Hello, ${name}`;
}
import greet, { PI, multiply } from './math.js';
// greet is the default, PI and multiply are named

Key Differences That Actually Matter

1. Synchronous vs Asynchronous Loading

require() is synchronous — it blocks until the module is loaded. ESM import is asynchronous by design. This is why ESM works in the browser without blocking the main thread.

In Node.js, you can also use dynamic imports in ESM:

// Works in both ESM and CJS environments
const { add } = await import('./math.js');

await import() is always asynchronous and returns a Promise.

2. Static vs Dynamic

In ESM, import declarations are static — they must be at the top level of a file and cannot be inside conditionals or loops.

// This is INVALID in ESM
if (condition) {
import something from './file.js'; // SyntaxError
}

In CJS, require() can go anywhere:

// Totally valid in CJS
if (condition) {
const something = require('./file');
}

For conditional loading in ESM, you use the dynamic import() function:

if (condition) {
const something = await import('./file.js');
}

3. this at the Top Level

In CommonJS, this at the top level of a module refers to module.exports. In ESM, this at the top level is undefined.

// CommonJS
console.log(this === module.exports); // true

// ESM
console.log(this); // undefined

4. __dirname and __filename

These convenient globals don’t exist in ESM. Instead, you reconstruct them:

// ESM equivalent of __dirname and __filename
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

How Node.js Decides Which System to Use

Node.js determines the module type based on:

  • Files with .mjs extension → treated as ESM
  • Files with .cjs extension → treated as CommonJS
  • Files with .js extension → depends on the nearest package.json

In your package.json, you can set:

{
"type": "module"
}

This makes all .js files in the package use ESM by default. Without this field, .js files default to CommonJS.

Get Budhdev kaushik’s stories in your inbox

Join Medium for free to get updates from this writer.

One thing I’ve noticed on production projects is that forgetting to set "type": "module" — especially in new Node.js projects — causes hours of debugging when you mix import and require without realizing it.

What Most Developers Miss

Live bindings in ESM. When you export a variable from an ESM module and later change it, the importing file sees the updated value. This is called a live binding.

// counter.js
export let count = 0;
export function increment() { count++; }
// app.js
import { count, increment } from './counter.js';

console.log(count); // 0
increment();
console.log(count); // 1 - live binding updates!

In CommonJS, you’d get a copy of the value at the time of require(). It wouldn't update.

CJS require() inside ESM. You can't use require() directly inside an ESM file. But you can use createRequire from the module package as a workaround:

import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const something = require('./legacy-cjs-file');

Tree-shaking only works with ESM. Bundlers like webpack and Rollup can eliminate dead code (unused exports) only when using ESM, because imports are statically analyzable. If you’re shipping a library and care about bundle size, this matters enormously.

import.meta is ESM-only metadata about the current module — including import.meta.url, which gives you the file's URL. There's no CJS equivalent.

Best Practices

For new projects: Default to ESM. It’s the official standard, it’s where the ecosystem is heading, and modern tooling supports it well.

For libraries: Publish dual packages (both ESM and CJS) using the exports field in package.json so consumers can use either:

{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}

Keep file extensions explicit in ESM imports. Node.js requires them, and being explicit prevents resolution ambiguity.

Don’t mix module systems in the same file. You can’t use import and require() in the same .js file without workarounds.

Use TypeScript with "module": "NodeNext" or "module": "ESNext" for modern ESM-aware resolution behavior.

Common Mistakes

Mistake 1: Missing .js extension in ESM imports

// Wrong in ESM Node.js
import { add } from './math';

// Correct
import { add } from './math.js';

The Node.js ESM resolver doesn’t auto-append extensions. It’ll throw ERR_MODULE_NOT_FOUND.

Mistake 2: Using __dirname in ESM

// Wrong — __dirname doesn't exist in ESM
const filePath = path.join(__dirname, 'data.json');

// Correct
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const filePath = join(__dirname, 'data.json');

Mistake 3: Assuming require() caching is always safe

A common mistake teams make is mutating an object returned by require(). Since all callers share the same cached instance, one mutation affects everyone:

// Dangerous pattern
const config = require('./config');
config.debug = true; // This modifies the shared cached object

Mistake 4: Mixing export default and named exports inconsistently

Default exports and named exports serve different purposes. A common pain point is a library that exports a default and then consumers try to destructure it:

// Library exports a default
export default { add, multiply };

// Consumer tries this - won't work as expected
import { add } from './math.js'; // undefined!

// Correct
import math from './math.js';
math.add(1, 2);

Use named exports when you have multiple things to export. Use default exports for the “main thing” a module provides.

Mistake 5: Not setting "type": "module" and wondering why import fails

If your package.json doesn't include "type": "module", Node.js treats .js files as CJS. You'll get a SyntaxError on import. Either add the field or rename your file to .mjs.

When to Use ESM

  • All new projects, especially those targeting modern Node.js (v14+) or browsers
  • Libraries that want to enable tree-shaking for consumers
  • Projects using modern bundlers (Vite, Rollup, esbuild) which are optimized for ESM
  • When you need top-level await (only available in ESM)

When to Stick With CommonJS

  • Maintaining legacy codebases where a full migration isn’t feasible
  • Writing CLI tools or scripts that need to require() modules dynamically based on runtime config
  • When a dependency you rely on doesn’t have an ESM build and you don’t want workaround complexity
  • Some older test runners or tooling that doesn’t fully support ESM yet

This becomes especially important when you’re integrating third-party libraries — always check whether they ship ESM, CJS, or both before committing to a module system in your project.

Key Takeaways

  • CommonJS uses require() / module.exports, loads synchronously, and has been the Node.js default since 2009.
  • ESModules uses import / export, is statically analyzed, and is the official JavaScript standard.
  • Node.js uses package.json's "type" field and file extensions (.mjs, .cjs) to determine which system to use.
  • ESM exports are live bindings — changes to exported values are reflected in importers. CJS exports are copies.
  • __dirname and __filename don't exist in ESM — use import.meta.url instead.
  • Tree-shaking only works with ESM. If you care about bundle size, this is significant.
  • For new projects, default to ESM. For libraries, consider shipping a dual package.

Closing Thoughts

The module system confusion isn’t your fault. JavaScript had no official standard for years, Node.js filled that gap with CommonJS, and the ecosystem split when ESM arrived. Now we’re in a transition period where both coexist — and understanding why they’re different is what helps you make the right call.

The good news: the ecosystem is clearly moving toward ESM. Most modern tools, frameworks, and libraries either already support it or are actively migrating. If you’re starting something new today, go with ESM from the beginning and avoid the confusion later.

If you’ve run into interesting edge cases with module systems in your own projects — especially around interop — I’d love to hear about them in the comments. These real-world scenarios are where the theory gets genuinely interesting.

Thank you for being a part of the community

Before you go:

Press enter or click to view image in full size

👉 Be sure to clap and follow the writer ️👏️️

👉 Follow us: Linkedin| Medium

👉 CodeToDeploy Tech Community is live on Discord — Join now!

Disclosure: This post includes affiliate and partnership links.

--

--

Budhdev kaushik
Budhdev kaushik

Written by Budhdev kaushik

Sr. Software Engineer | MERN Stack Developer. Sharing backend & React insights. Connect: https://www.linkedin.com/in/budhdevkaushik⁠ | Open to Work