DevLift
Back to Blog

Node.js Modules: CommonJS vs ESM, Circular Dependencies, and How Node Actually Resolves Your Imports

Node.js has two module systems that don't interoperate cleanly. Here's how CommonJS and ESM work, how Node resolves imports, and how circular dependencies behave in each.

Admin
June 26, 202610 min read2 views

Node.js Modules: CommonJS vs ESM, Circular Dependencies, and How Node Actually Resolves Your Imports

You've definitely hit this wall: you add "type": "module" to your package.json, restart your server, and suddenly half your require() calls explode. Or you try to import a package and get ERR_REQUIRE_ESM. Or you just want to know why __dirname doesn't exist in your .mjs file.

Node.js has two module systems that live in the same runtime, don't always interoperate cleanly, and have subtly different behavior around caching, circular dependencies, and resolution. This is the guide I wish I had when I first ran into all three of those problems in the same afternoon.

The Two Systems

Before we get into how they work, here's the fast summary of why both exist:

CommonJS was Node's original module system, designed before JavaScript had a standard module format. It's synchronous, dynamic, and has been powering Node.js since 2009. ES Modules (ESM) is the JavaScript standard introduced in ES2015, asynchronous, statically analyzable, and designed to work in both browsers and servers.

Node supports both. That's both a feature and the source of most of the confusion.

CommonJS: How It Actually Works

The require() function doesn't just read a file. Every CommonJS module gets wrapped in a function before execution:

// What you write (math.js):
function add(a, b) { return a + b; }
module.exports = { add };
 
// What Node actually executes:
(function(exports, require, module, __filename, __dirname) {
  function add(a, b) { return a + b; }
  module.exports = { add };
});

This wrapper is why __dirname, __filename, require, module, and exports are available without importing them. They're injected as function parameters. Not globals — locals. You can see the arity from inside a CJS module: console.log(arguments.length) prints 5.

The other thing to know: CommonJS caches modules. The first require('./math') executes the file and stores the result. Every subsequent require('./math') returns the same cached object — the module doesn't re-execute.

// counter.js
let count = 0;
module.exports = {
  increment: () => ++count,
  get: () => count,
};
 
// app.js
const a = require('./counter');
const b = require('./counter'); // same object — same cache entry
 
a.increment();
console.log(b.get()); // 1, not 0

This caching is what makes singletons work in Node without any extra machinery.

ES Modules: Live Bindings and Static Analysis

ESM is structurally different. The imports are static — Node parses them before executing any code, builds a module graph, then evaluates modules in dependency order.

// math.mjs
export function add(a, b) {
  return a + b;
}
 
export const PI = 3.14159;
 
// app.mjs
import { add, PI } from './math.mjs'; // the extension is not optional

Two things stand out. First, the extension: Node's ESM resolver does no extension guessing on relative specifiers. Drop the .mjs and you get an error, not a fallback:

$ node --input-type=module -e "import './math';"
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/math' imported from /tmp/[eval1]

Same story for directories — import './dir' throws ERR_UNSUPPORTED_DIR_IMPORT even when dir/index.mjs exists. Second, the exports are live bindings, not value copies.

// counter.mjs
export let count = 0;
export function increment() { count++; }
 
// app.mjs
import { count, increment } from './counter.mjs';
 
console.log(count); // 0
increment();
console.log(count); // 1 — the live binding updated

In CommonJS, const { count } = require('./counter') would give you a copy of the number at that moment. In ESM, import { count } gives you a live reference to the exported binding. When the module updates count, you see the new value.

ESM also supports top-level await:

// config.mjs — note the absolute URL; Node's fetch has no page to be relative to
const config = await fetch('https://config.internal/api/config').then(r => r.json());
export { config };

You can't do this in CommonJS without wrapping everything in an async function. Hold onto that fact — it's what makes require() of an ESM module conditional rather than universal, which we'll get to.

How Node Decides Which System to Use

Node determines the module format per-file using these rules, in order:

  1. .mjs extension → always ESM
  2. .cjs extension → always CommonJS
  3. .js extension → check the nearest package.json for "type"
    • "type": "module" → ESM
    • "type": "commonjs" or no "type" field → CommonJS
// package.json
{
  "name": "my-package",
  "type": "module"  // all .js files in this package are ESM
}

The "type" field only affects .js files. .mjs is always ESM and .cjs is always CommonJS regardless of "type". Use these extensions when you need to mix formats within a project that has "type": "module" set.

How Node Resolves Module Paths

The resolution algorithm differs between CommonJS and ESM.

CommonJS does a lot of guessing for you:

  • require('./math') tries math, math.js, math.json, math.node, math/index.js
  • require('lodash') walks up directory tree looking for node_modules/lodash

ESM is strict:

  • import './math' — no extension guessing, ERR_MODULE_NOT_FOUND
  • import './math.js' — correct. And yes, you write .js even when the source file on disk is math.ts: TypeScript does not rewrite specifiers on emit, so the extension in your source has to be the one that exists at runtime
  • import 'lodash' — bare specifier, resolved through node_modules and the exports field

The exports field in package.json is how modern packages control what's importable:

{
  "name": "my-library",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    }
  }
}

When you import 'my-library', Node matches the "import" condition and resolves to dist/index.mjs. When you require('my-library'), it uses dist/index.cjs. The exports field also restricts what can be imported, and it's worth knowing the exact error because it's the one you'll see in a bug report from a user of your package:

$ node -e "import('my-library/internal').catch(e => console.log(e.code, e.message))"
ERR_PACKAGE_PATH_NOT_EXPORTED Package subpath './internal' is not defined by
  "exports" in .../node_modules/my-library/package.json

That fires even when the file exists, and even if the consumer reaches for the real path (my-library/dist/internal.mjs) instead of a subpath alias. Once you ship an exports map, the rest of your package is genuinely private.

Rendering diagram...

The Interop Problem

Here's where things get messy. The two systems don't interoperate symmetrically.

ESM importing CJS: Works fine. The ES module namespace for a CJS module always has a default key pointing at module.exports. Named exports are a bonus on top, detected by static analysis — cjs-module-lexer reads the source looking for assignment patterns it recognises. "Aren't always reliable" is the usual hand-wave; the rule is actually crisp. A static object literal is detected:

// utils.cjs
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
module.exports = { add, subtract };
// app.mjs — both of these work
import utils from './utils.cjs';    // default: the whole module.exports
import { add } from './utils.cjs';  // named: detected by the lexer

A computed assignment is not, and the failure is a load-time SyntaxError, not a runtime undefined:

// dyn.cjs
const k = 'add';
module.exports[k] = (a, b) => a + b;
SyntaxError: Named export 'add' not found. The requested module './dyn.cjs' is a
CommonJS module, which may not support all module.exports as named exports.

So: if you own the CJS file, keep the export shape statically visible. If you don't, take the default import and destructure it yourself.

CJS importing ESM: the hard direction, because require() is synchronous and ESM evaluation can be asynchronous. Before this was solved, the only route was dynamic import:

// Works on every Node version with ESM support
async function loadModule() {
  const { default: esmModule } = await import('./my-module.mjs');
  return esmModule;
}

Node 22 changed that — but be precise about which Node 22, because this bites in CI. require(esm) landed behind --experimental-require-module in 22.0 and only became the default in 22.12.0 (it was already on by default in 23.x). On 22.0 through 22.11 without the flag you still get ERR_REQUIRE_ESM.

$ node -v
v22.22.3
$ node -e "const m = require('./math.mjs'); console.log(m.add(1, 2), Object.keys(m))"
3 [ 'PI', 'add' ]

Note what came back: the module namespace, not module.exports. A module with a default export shows up as { __esModule: true, default: …, … }, so require('./x.mjs') gives you an object with .default on it, not the default itself.

⚠️

require(esm) does not silently fail on top-level await — it throws, loudly, with a code you can catch and a flag that tells you where the TLA came from:

$ node -e "try { require('./tla.mjs') } catch (e) { console.log(e.code, '|', e.message) }"
ERR_REQUIRE_ASYNC_MODULE | require() cannot be used on an ESM graph with
top-level await. Use import() instead. To see where the top-level await comes
from, use --experimental-print-required-tla.

The catch is that it's the whole graph, not just the entry point: one transitive dependency with a top-level await is enough. If you publish a library and want to stay requireable, that's a constraint on your dependency tree, not just your own source.

Circular Dependencies: Where Things Get Weird

Circular dependencies exist in both systems, but they fail differently. This is the part that bites people the hardest.

CommonJS circular dependency behavior:

// a.cjs
const b = require('./b.cjs');
console.log('a loaded, b.value:', b.value);
exports.value = 'from a';
 
// b.cjs
const a = require('./a.cjs');
console.log('b loaded, a.value:', a.value); // undefined — a isn't done yet
exports.value = 'from b';
 
// index.cjs
require('./a.cjs');

Run node index.cjs and here's what you get:

b loaded, a.value: undefined
a loaded, b.value: from b
Warning: Accessing non-existent property 'value' of module exports inside circular dependency

When Node starts loading a.cjs, it adds a to the cache immediately with an empty exports object. Then a requires b. b requires a — Node sees a in cache and hands back the empty {}. So a.value is undefined inside b, and b finishes loading first, which is why its log line comes out on top.

This is a partial-module problem: you get back whatever module.exports contained at the moment the cycle was closed, which is often nothing at all. Note the third line — Node emits a real process warning when you touch a missing property on a module that's mid-cycle. That warning is the cheapest circular-dependency detector you have, and almost nobody grep's their logs for it.

ESM circular dependency behavior:

ESM handles cycles differently because of live bindings and the two-phase evaluation:

// a.mjs
import { bValue } from './b.mjs';
export const aValue = 'from a';
console.log('a loaded, bValue:', bValue);
 
// b.mjs
import { aValue } from './a.mjs';
export const bValue = 'from b';
console.log('b loaded, aValue:', aValue);

node a.mjs doesn't print a partial value like CJS did. It doesn't print anything:

file:///tmp/b.mjs:3
console.log('b loaded, aValue:', aValue);
                                 ^
ReferenceError: Cannot access 'aValue' before initialization
    at file:///tmp/b.mjs:3:34

Exit code 1. Neither console.log runs — b is evaluated first (depth-first, post-order), reaches the read of aValue while a is still mid-evaluation, and the whole process dies.

That's the actual tradeoff, and it's a good one: ESM first links the entire module graph, resolving every binding, then evaluates. Reading a binding whose module hasn't finished evaluating is a temporal dead zone violation and it throws. CJS hands you undefined and lets you ship it. Read the binding after initialization completes — from inside a function that runs later — and it works, because the binding is live.

// Safe circular pattern in ESM — export functions, not values
// a.mjs
import { getB } from './b.mjs';
export function getA() { return 'from a'; }
export function useB() { return getB(); }
console.log('a top-level: useB() =', useB());
 
// b.mjs
import { getA } from './a.mjs';
export function getB() { return 'from b'; }
export function useA() { return getA(); }
console.log('b top-level: useA() =', useA());
$ node a.mjs
b top-level: useA() = from a
a top-level: useB() = from b

Both work, at top level, across the cycle — which surprised me the first time. The reason is that function declarations are hoisted and initialized when the module environment is instantiated, before any module body runs, so they're never in the dead zone. const and let are hoisted but left uninitialized, which is exactly what blew up in the previous example. Swapping export const x = … for export function getX() is not a stylistic preference in a cyclic graph; it's the difference between working and throwing.

Better still: break the cycle by extracting shared code into a third module that both import.

// shared.js — extracted dependency
export const SHARED_CONFIG = { timeout: 3000 };
 
// a.mjs
import { SHARED_CONFIG } from './shared.js';
 
// b.mjs
import { SHARED_CONFIG } from './shared.js';

Dual Packages: Shipping CJS and ESM Together

If you're publishing an npm package, you'll want to support both CommonJS and ESM consumers. The exports field handles this:

{
  "name": "my-library",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  }
}

A build tool like tsup can produce both outputs from a single TypeScript source:

tsup src/index.ts --format cjs,esm --dts
⚠️

Dual packages have a hazard: if both the CJS and ESM versions get loaded in the same process (via different import paths), you end up with two instances of your module. Singletons, caches, and any shared state will be duplicated. Use instanceof checks carefully in library code.

Getting __dirname in ESM

__dirname and __filename don't exist in ESM modules. Every Stack Overflow answer from before 2024 tells you to do this:

// the old dance — you don't need this anymore
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
 
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, 'config.json');

Stop writing that. import.meta.dirname and import.meta.filename were added in v20.11.0 and v21.2.0, and lost their experimental label in v22.16.0:

import { join } from 'node:path';
const configPath = join(import.meta.dirname, 'config.json');

One caveat from the docs, and it's the reason the old dance still exists in library code: both properties are "only present on file: modules". Import a data: URL module and read them and you get undefined for both — so if you're writing something that might be loaded through a custom loader or a non-file: specifier, keep the import.meta.url fallback.

When Not to Reach for ESM (Yet)

ESM is the right long-term choice, but there are situations where it's not worth the migration pain:

Existing CommonJS codebases with lots of dynamic require(): CommonJS allows require() inside conditionals, loops, and functions. ESM import is always static (though dynamic import() works). If you're heavily using dynamic require patterns, migration is more work.

Node scripts and CLIs that run on older Node versions: If you need to support Node 16 or earlier, ESM interop is much worse. You'd be limiting yourself to dynamic import from CJS everywhere.

Internal monorepo packages: If the package is only consumed internally and all consumers already use TypeScript (which handles module format at build time), there's no external compatibility concern. Pick one format and be consistent.

Anything touching require.resolve(): import.meta.resolve() is the ESM equivalent and it is synchronous — it has returned a string rather than a Promise since v20.0.0/v18.19.0 — but it is not a drop-in replacement, and the docs still mark it Stability 1.2 (release candidate). Two concrete differences:

$ node --input-type=module -e "console.log(import.meta.resolve('./nope.js'))"
file:///tmp/nope.js
$ node -e "require.resolve('./nope.js')"
Error: Cannot find module './nope.js'   (MODULE_NOT_FOUND)

It returns a URL string, not a filesystem path (you still need fileURLToPath), and since v20.6.0 it deliberately does not throw for file: targets that don't exist. If your tool's logic was "call resolve, catch the throw, fall back", that logic silently stops working. It does throw ERR_MODULE_NOT_FOUND for a bare specifier that isn't installed, so the behaviour differs depending on specifier kind — which is its own trap.

The Practical Checklist

For new projects: use "type": "module" in package.json and write ESM. Set "moduleResolution": "nodenext" in tsconfig.json and write the runtime extension (./math.js) in your imports — TypeScript will not rewrite it for you, and "moduleResolution": "bundler" lets you omit it, which produces source that only works if a bundler is in the path.

For existing CJS projects you can't migrate: use require() normally and reach for dynamic import() when you need an ESM-only package. On 22.12+ you often won't have to.

For npm libraries: ship dual packages with conditional exports. Use tsup or unbuild to produce both .cjs and .mjs outputs without maintaining two source trees.

For debugging module issues, in this order:

  1. NODE_DEBUG=module node app.js — dumps the CJS resolver's decisions, including the exact node_modules chain it searched. This is the tool nobody reaches for and it answers most "why isn't it finding it" questions in one line.
  2. Read the error code, not the message: ERR_MODULE_NOT_FOUND (missing extension or file), ERR_UNSUPPORTED_DIR_IMPORT (you imported a directory), ERR_PACKAGE_PATH_NOT_EXPORTED (the package's exports map is refusing you), ERR_REQUIRE_ASYNC_MODULE (top-level await somewhere in the graph). Each one points at a different fix.
  3. Check the exports field before you check anything else when a package says it can't be required.
  4. Remember the "type" field only affects .js files — .mjs and .cjs always win.

The module system confusion mostly comes from the ecosystem being mid-transition. New packages ship ESM-only. Old packages ship CJS. Most are in between. Understanding how Node decides which system to use, how resolution works with the exports field, and how circular dependencies behave in each system means you can debug your way out of most of it.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
Reading a 2 GB file all at once doesn't run out of memory — it hits V8's 512 MB string limit first. Streams process it in flat memory instead. Here's the pipeline pattern, custom Transform streams, and backpressure, with the numbers measured on Node 22.
AdminAugust 3, 20266 min read
Node.js runs on one CPU core by default. The cluster module lets you fork N workers across all cores — here's the pattern, graceful shutdown, IPC, and when PM2 makes it unnecessary.
AdminAugust 3, 20266 min read