1. Home
  2. Blog
  3. Software Development

Software Development

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.

11 min read
ES modules vs CommonJS: how JavaScript imports work in Node.js, tested

Key takeaways

  • 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 .mjs or its package.json has "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:

  • .mjs files are always ES modules; .cjs files are always CommonJS.
  • .js files follow the nearest package.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 module and moduleResolution to 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

Work with Stack Quora

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.

Keep reading

More guides on testing, AI & modern development

Enjoyed this article? Pick a topic and jump straight into our newest tutorials.

Artificial Intelligence

Prompt Injection Explained: How AI Apps Get Tricked and How to Defend Them

Prompt injection explained for builders: direct and indirect attacks, why keyword filters fail (our test blocked 1 of 4), and…

Read article →
DevOps

DNS Records Explained: A, AAAA, CNAME, MX, TXT and TTL With Real Lookups

DNS records explained with lookups we ran: how resolution works, A and AAAA, CNAME rules, MX and null MX, TXT…

Read article →
DevOps

Feature Flags Explained: Safe Releases, Gradual Rollouts and Flag Hygiene

Feature flags explained with a rollout simulation we ran: flag types, percentage rollouts with stable hashing, kill switches, allow lists,…

Read article →
Test Automation

Test Data Generation With Python Faker: Realistic, Repeatable and Safe Data

Test data generation with Python Faker, run and measured: seeding for repeatable data, locales, unique values, domain-shaped records, edge cases…

Read article →
Software Testing

Shift-Left Testing Explained: Find Bugs Earlier Without Slowing the Team

Shift-left testing explained: what it means, why early defects are cheaper, practices from requirement reviews to CI checks, the tester's…

Read article →
Databases & SQL

SQL String Functions Explained: TRIM, SUBSTR, REPLACE, CONCAT and Data Cleanup

SQL string functions explained with queries we ran: concatenation and NULL, TRIM, UPPER and LOWER, SUBSTR and INSTR, REPLACE, padding,…

Read article →
Databases & SQL

SQL Triggers Explained: BEFORE and AFTER Triggers, Audit Logs and Pitfalls

SQL triggers explained with triggers we ran: BEFORE vs AFTER, OLD and NEW, audit logs, validation, stock updates, WHEN conditions,…

Read article →
Software Development

Python JSON Module Explained: dumps, loads, Custom Types and Common Errors

The Python JSON module explained with code we ran: dumps and loads, files, type conversions, dates and decimals, custom encoders,…

Read article →
Software Development

ES Modules vs CommonJS: How JavaScript Imports Work in Node.js, Tested

ES modules vs CommonJS explained with code we ran in Node.js: import and export syntax, require, live bindings, caching, top-level…

Read article →
Software Development

Python Regex Explained: Patterns, Groups, Lookaheads and Pitfalls, Tested

Python regex explained with patterns we ran: search vs match, findall, groups and named groups, greedy vs lazy, sub, lookaheads,…

Read article →
Artificial Intelligence

LLM Structured Output Explained: Getting Reliable JSON From AI Models

LLM structured output explained with validation we ran in Pydantic: JSON mode and schemas, constrained decoding, the ways model output…

Read article →
DevOps

SSH Keys Explained: How They Work, ssh-keygen, Ed25519 and Safe Habits

SSH keys explained with keys we generated: public vs private keys, ssh-keygen, Ed25519 vs RSA, fingerprints, passphrases, ssh-agent, authorized_keys, config…

Read article →
DevOps

HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test

HTTP caching explained with a small server we ran: Cache-Control directives, max-age, no-cache vs no-store, ETag revalidation and 304 responses,…

Read article →
Test Automation

API Contract Testing Explained: Catch Breaking Changes Before They Ship

API contract testing explained with a schema check we ran: what a contract is, consumer-driven contracts, Pact, OpenAPI, which changes…

Read article →
Software Testing

Risk-Based Testing Explained: Score Risks, Build a Matrix and Test What Matters

Risk-based testing explained step by step: identifying risks, scoring likelihood and impact, building a risk matrix, mapping risk to test…

Read article →
Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

SQL constraints explained with tests we ran: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY and FOREIGN KEY, the NULL loopholes, SQLite's…

Read article →
Databases & SQL

SQL Date Functions Explained: Adding, Comparing and Grouping Dates Without Bugs

SQL date functions explained with queries we ran: adding intervals, month-end surprises, date differences, the BETWEEN trap, grouping by day,…

Read article →
Software Development

Python asyncio Explained: async, await, gather and TaskGroup With Real Timings

Python asyncio explained with code we timed: coroutines, await, gather, TaskGroup, error handling, timeouts, limiting concurrency and the blocking-call mistake…

Read article →
Software Development

Debounce vs Throttle in JavaScript: How They Work, When to Use Each, Tested

Debounce vs throttle in JavaScript explained with timings we measured in Node.js: how each works, simple implementations, leading and trailing…

Read article →
Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

Python context managers explained with code we ran: how with works, __enter__ and __exit__, contextlib.contextmanager, suppress, ExitStack, database transactions and…

Read article →
Artificial Intelligence

LLM Temperature and Top-p Explained: How AI Models Choose Their Next Word

LLM temperature and top-p explained with a simulation we ran: how probabilities are reshaped, what top-p keeps, recommended settings by…

Read article →
DevOps

YAML Explained: Syntax, the Norway Problem and Other Traps We Tested

YAML explained with a parser test: basic syntax, lists and maps, the Norway problem, versions turning into floats, octal zip…

Read article →
DevOps

Cron Jobs Explained: Syntax, Real Schedules and the Mistakes That Break Them

Cron jobs explained with schedules we verified: the five fields, operators, real examples, the day-of-month OR day-of-week trap, environment problems,…

Read article →
Software Testing

Bug Life Cycle in Software Testing: Every Status, Who Owns It and Common Mistakes

The bug life cycle explained: every defect status from New to Closed, who owns each step, rejected, deferred and reopened…

Read article →
Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Property-based testing explained with Hypothesis tests we ran: properties vs examples, strategies, shrinking, the real bugs it found in our…

Read article →
Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

SQL pagination explained with a million-row test we ran: LIMIT and OFFSET, why deep pages get slow, keyset (cursor) pagination,…

Read article →
Databases & SQL

SQL EXISTS vs IN: Differences, the NOT IN NULL Trap and Performance

SQL EXISTS vs IN explained with queries we ran: how each works, why NOT IN returns no rows when a…

Read article →
Artificial Intelligence

LLM Tokens Explained: How AI Models Read Text, Count Costs and Fill Context Windows

LLM tokens explained with a tokenizer we ran: how text is split, why counts differ by language and tokenizer, context…

Read article →
Software Testing

The Test Pyramid Explained: Layers, Ratios and When to Break the Shape

The test pyramid explained: unit, integration and end-to-end layers, sensible ratios, the ice cream cone anti-pattern, the testing trophy and…

Read article →
Test Automation

pytest Fixtures Explained: Scope, yield Teardown, conftest.py and Factories

pytest fixtures explained with a suite we ran: how fixtures work, scopes, yield teardown, conftest.py, factory fixtures, parametrized fixtures, built-ins…

Read article →
Artificial Intelligence

AI Hallucinations Explained: Why Language Models Make Things Up and How to Reduce It

AI hallucinations explained: what they are, why language models produce them, common types, how to reduce them with grounding and…

Read article →
Test Automation

Mocking in Python: unittest.mock, patch and the Mistakes That Make Tests Lie

Mocking in Python with unittest.mock, tested: Mock and return_value, side_effect, patching where a name is used, spec and autospec, mocking…

Read article →
Software Testing

Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means

Code coverage explained with a real coverage.py run: line, branch and condition coverage, reading a report, why 100% coverage can…

Read article →
Artificial Intelligence

What Are AI Agents? How They Work, With a Simple Agent Loop You Can Run

What are AI agents? A plain guide to the agent loop, tools, memory and guardrails, with a small Python loop…

Read article →
Test Automation

TDD vs BDD: Key Differences, One Feature Built Both Ways and When to Use Each

TDD vs BDD explained with one discount feature built both ways: red-green-refactor with code we ran, Given-When-Then scenarios, tools, trade-offs…

Read article →
Software Testing

User Acceptance Testing (UAT): Process, Checklist and Real Examples

User acceptance testing explained: who runs it, how it differs from QA, a seven-step process, a UAT test case template,…

Read article →