This article is published in English.
CommonJS vs ES Modules: The Structural Split Behind Node's Import Errors
Learn why require and import are fundamentally different systems, how static analysis shapes tree-shaking, and why default exports and circular imports behave inconsistently across both.
Nearly every baffling module error you hit while working with Node traces back to one fact that rarely gets spelled out clearly. Messages like a reference complaining that require doesn't exist in module scope, or a syntax error about using import outside a module, or a package that behaves inconsistently depending on how you pull it in — all of these point to the same root cause: CommonJS and ES Modules are not just two spellings of the same idea. They are two genuinely distinct systems. One is built on function calls that execute the moment they're invoked; the other is built on a structure the engine can inspect before anything actually runs. Almost every rough edge you encounter when the two meet is a direct side effect of that split.
CommonJS: require Is Just a Function Call
It's easy to forget, once you've typed require() a thousand times, that there's nothing magical about it. It's a plain function, and module.exports is a plain object — both supplied by Node at runtime, not baked into the language itself.
// math.js
function add(a, b) { return a + b; }
module.exports = { add };
// app.js
const math = require("./math.js"); // a plain function call, evaluated when this line runs
console.log(math.add(2, 3));
Because require behaves like any other function, you're free to call it conditionally: inside an if block, inside a try/catch, or with a path computed from a variable — anything a regular function call allows.
const driver = require(process.env.DB_DRIVER === "postgres" ? "./pg-driver" : "./sqlite-driver");
That flexibility is genuinely handy, and it's also precisely what ES Modules chose to sacrifice.
ES Modules: The Engine Reads the Structure Before It Runs Any Code
import is not a function call — it's a declaration, and it comes with a rule that catches almost everyone off guard the first time: it has to sit at the top level of a file. You can't tuck it inside a condition, a loop, or a function body.
// math.mjs
export function add(a, b) { return a + b; }
// app.mjs
import { add } from "./math.mjs"; // not evaluated like a function call
console.log(add(2, 3));
if (needsMath) {
import { add } from "./math.mjs"; // SyntaxError, this is not allowed
}
That constraint isn't arbitrary pickiness. It exists because ES Modules are meant to be statically analyzable: before a single line of your program actually runs, the engine walks through every import and export across the whole module graph and assembles a complete map of what depends on what. That static map is what enables tree-shaking — a bundler can inspect the graph and safely strip out code that's exported but never imported anywhere, because the dependency relationships are known ahead of time rather than something that only becomes visible as execution unfolds. CommonJS can't make that same promise, since require() calls can be conditional, computed, or buried inside logic that only resolves while the program is running — the very flexibility that makes CommonJS's dependency graph impossible to know in advance.
Default Exports Don't Mean the Same Thing in Both Systems
This is where interop actually starts to hurt. In CommonJS, writing module.exports = something simply replaces what the entire module is — there's no separate notion of "the default export" that stands apart from any other export:
// legacy.js
module.exports = function greet(name) {
return `Hello, ${name}`;
};
ESM, on the other hand, treats the default export as an explicit, structurally separate concept:
// modern.mjs
export default function greet(name) {
return `Hello, ${name}`;
}
When Node's interop layer loads a CommonJS file from ESM code, it takes the whole module.exports value and wraps it as the default export. That's usually the sensible behavior, but it also creates exactly the kind of subtle mismatch that trips people up:
import greet from "./legacy.js"; // works: greet is the whole module.exports value
import { greet } from "./legacy.js"; // fails silently or throws, depending on the module
// named destructuring assumes CommonJS explicitly attached named properties,
// which module.exports = function... never did
That single ambiguity — whether what you're importing is the entire module or just one named piece of it — accounts for a large portion of "why is this undefined" bugs the moment a codebase mixes older CommonJS packages with newer, ESM-first code.
Circular Imports Resolve Differently, and It Actually Matters
Two modules importing each other is already a delicate arrangement in any module system, but CommonJS and ESM handle that fragility in different ways, which means the same-looking code can fail differently depending on which system it's running in.
// a.js (CommonJS)
const b = require("./b.js");
console.log("b's value:", b.value);
module.exports = { value: "from a" };
// b.js (CommonJS)
const a = require("./a.js");
console.log("a's value:", a.value); // undefined — a hasn't finished exporting yet
module.exports = { value: "from b" };
CommonJS handles this by returning whatever the circularly-required module's module.exports happens to be at that exact instant, even if that module hasn't finished executing yet. That's why a.value turns up as undefined when read from inside b.js — a.js hadn't yet reached its module.exports assignment by the time b.js asked for it.
ESM takes a different approach through what's known as live bindings: references that stay linked to the exporting module and update automatically once that module finishes evaluating, instead of a snapshot frozen at import time. That means certain circular patterns work correctly under ESM that would quietly yield undefined under CommonJS. But this doesn't turn circular imports into a good idea in either system — it just changes how the failure shows up, rather than eliminating it.
The Practical Trap: Mixing Them in One Project
Day-to-day, the pain isn't really conceptual — it boils down to a specific, recurring cluster of errors:
SyntaxError: Cannot use import statement outside a module
ReferenceError: require is not defined in ES module scope
ReferenceError: exports is not defined
These surface because Node has to figure out which module format a given file is written in, and it makes that call using several signals: whether the file ends in .mjs, whether it ends in .cjs, or, failing either of those, what the nearest package.json declares through its "type" field. Whenever a file's actual syntax doesn't line up with how Node has decided to interpret it, you get exactly these errors. A package published purely as ESM simply cannot be pulled in with require() from CommonJS code. To use it, a project needs either the asynchronous import() — which, unlike the static import keyword, behaves as a real function call and can be used anywhere, conditionals included — or a full migration of the consuming code over to ESM.
// this works from CommonJS, because import() is a dynamic function call, not a static declaration
async function loadEsmOnlyPackage() {
const mod = await import("esm-only-package");
return mod.default;
}
The One Fact Underneath All of This
Every individual friction point — the requirement that import sit at the top level, tree-shaking being viable in one system but not the other, mismatched default exports, and circular imports behaving differently — traces back to a single underlying cause: CommonJS builds its module graph dynamically, as the program runs, while ESM builds its graph statically, before any code executes. Neither approach is a flaw in the other's design; both are answering the same question — "how do files depend on each other?" — but with genuinely different constraints and genuinely different guarantees. The friction you feel when mixing them isn't Node being broken. It's two internally consistent systems being forced to communicate at the boundary where they meet.