skip to content

ESM and CommonJS Interop

The two module systems JavaScript actually ships with, and the friction where they meet. Interviewers care because most real codebases still mix them, and the mismatch produces confusing import-time failures that only make sense once you know both models.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

explore

questions

12

In a CommonJS module, when you write `const cfg = require('./config')`, at what point does the code inside config.js actually run, and what value does require() hand back?

level: juniorimportance: must knowfreq 68%

answer

  1. a call, not a declaration
  2. blocking, nothing interleaves
  3. body runs to completion first
  4. you get module.exports, by reference
  5. seeded as an empty object

basics

~10 s

require() runs the target module's body synchronously at the call site and hands back whatever value module.exports holds when that body finishes. The calling file is blocked until the required module completes.

solid answer

~40 s

`require('./config')` is an ordinary function call, not a declaration, so it does its work exactly where it appears. It runs config.js's body to completion synchronously — the calling module is blocked meanwhile, so any logging, config parsing or table building at the top level of config.js happens right at that line. When the body finishes, `require` returns the current value of that module's `module.exports`, which is seeded as an empty object `{}` and is whatever the module attached properties to or replaced it with. The caller receives that object by reference, not a copy: it holds the very same object the module holds. If the module body throws, the exception propagates straight out of the `require()` call like any other synchronous error, so a `try/catch` around `require` catches it.

go deeper

for a junior

Be able to say plainly that require() runs the other file's code right there and gives you back that file's module.exports. Know that it is synchronous and returns a plain value, never a promise.

for a middle

Explain the mechanics: module.exports starts as an empty object, the body runs to completion, and the value in module.exports at that moment is what the caller receives — by reference, so both sides hold one object.

for a senior

Show that you reason about what runs at load time. Discuss depth-first nested loading, errors propagating synchronously out of the require call, and why a module that swaps module.exports after loading breaks callers who already hold the old object.

for a principal

Own the design implication: because loading is blocking and ordering is call-order, module bodies are a shared startup budget and an implicit initialisation contract. Set conventions about what may run at load time before the codebase accumulates hidden startup coupling.

## require is a function, not a declaration CommonJS is the module system Node adopted before ECMAScript had one of its own; it is not part of the ECMAScript language specification. Inside a CommonJS file you get three names to work with: `require`, `module`, and `exports`. The most important consequence of `require` being a plain function is that it has no special syntactic position and no separate pre-pass. It can sit at the top of a file, inside an `if`, inside a function body, or in the middle of an expression, and it does its work at the moment control reaches it. ```js // main.js console.log('before'); const cfg = require('./config'); // config.js runs here, right now console.log('after', cfg.port); ``` That is already a meaningful difference from a declarative import form, where the loading is part of the module's static structure rather than a statement that executes in order. ## The module object and its exports Before a module's body runs, the host creates a `module` object for that file whose `exports` property is initialised to a fresh empty object, and a local variable `exports` initialised to that same object. The body then either attaches properties to it or replaces `module.exports` outright. When the body finishes, the value currently stored in `module.exports` is exactly what `require` returns to the caller. That is the whole contract: *whatever `module.exports` points at when the body ends*. A file that never touches its exports still returns something — the empty object it was seeded with: ```js // side-effect-only.js globalThis.installed = true; // main.js const m = require('./side-effect-only'); console.log(m); // {} — the seeded object, not undefined ``` That is why `require('./x').doThing()` on a module that forgot to export throws `TypeError: ... is not a function` rather than a "module not found" style error: you got an object, just an empty one. ## Synchronous, run-to-completion loading The load is synchronous and runs the module body to completion. Nothing else in the program interleaves with it. If the required module itself requires other modules, those calls happen inside its body, so loading proceeds depth-first, and each nested body finishes before the `require` that triggered it returns. ```js // a.js console.log('a start'); require('./b'); console.log('a end'); // b.js console.log('b'); ``` Requiring `a.js` prints `a start`, `b`, `a end`. The ordering is entirely explained by ordinary synchronous function-call semantics — there is no queue, no callback, no promise involved. This is also why you cannot `await require(...)` meaningfully: `require` does not return a promise, so awaiting it just wraps an already-available value. ## Returned by reference, not copied The caller and the module end up holding the same object. If the module later mutates a property on the object it exported, holders see the change, because there is one object. What does *not* propagate is a later reassignment of `module.exports` itself: a caller that already captured the old object keeps pointing at the old object, since it holds a reference to a value, not a live view of the module's `module.exports` slot. ```js // late.js module.exports = { ready: false }; setTimeout(() => { module.exports = { ready: true }; }, 0); // a caller that already required this file still sees { ready: false } ``` The practical rule that falls out: if a module needs to publish something that changes over time, mutate a property on the exported object or expose a function, rather than swapping the exports object out from under everyone. ## When loading throws A module body is just code, so it can throw. The exception travels out of the `require()` call to the requiring module's stack frame, and can be caught: ```js try { const plugin = require('./optional-plugin'); } catch (err) { // the module body threw, or the file could not be loaded } ``` Because the failure is synchronous, there is no rejected promise and no unhandled-rejection warning involved. ## Interview traps to be ready for The common wrong answers are that `require` returns a promise, that it schedules loading in the background, or that the returned value is some special namespace wrapper. All three are the same misunderstanding: `require` is a normal, blocking function call that returns an ordinary JavaScript value. The second trap is expecting `require` to hoist. It does not; a `require` placed inside a function body simply does not run until that function is called, which is the entire basis of the lazy-require idiom.

  • If a file never assigns to module.exports at all, what does require() of it return?
    An empty object. Each module's `module.exports` is seeded with a fresh `{}` before the body runs, so a file that only does side effects still hands back that object. That is why calling a missing export throws `TypeError: x is not a function` rather than something about a missing module — you got an object, it just has no properties.
  • Does a require() call have to appear at the top of a file?
    No. It is an ordinary function call, so it can be conditional or sit inside a function body, and it does nothing until control reaches it. Deferring a require into the function that needs it moves the module's load-time work off the startup path. The trade-off is that the load cost, and any error it throws, now shows up at call time instead of at startup.
  • What happens if the required module's body throws while it is loading?
    The exception propagates synchronously out of the `require()` call into the requiring module, exactly like an error thrown by any function you call. A surrounding `try/catch` catches it, and the assignment target never receives a value. Nothing async is involved, so there is no rejected promise and no unhandled-rejection path to worry about.

saying these in an interview costs you the question

  • Says require() returns a promise you should await
  • Thinks require() loads the file asynchronously in the background
  • Believes require() hoists to the top like a declaration
  • Assumes a module with no exports returns undefined
  • Thinks the caller gets a deep copy of the exported object

context

open as a page

In a CommonJS module, what is the difference between `exports.parse = fn` and `module.exports = { parse: fn }`, and why does writing `exports = { parse: fn }` export nothing at all?

level: middleimportance: must knowfreq 74%

basics

~20 s

exports is a local variable that initially points at the same object as module.exports, and require() returns module.exports. Attaching properties through exports works; assigning a new object to exports only rebinds the local variable, so importers still get the original empty object.

open as a page

A CommonJS file `logger.cjs` ends with `module.exports = function log(msg) { console.log(msg); }`. In Node's native ESM, what does `import log from './logger.cjs'` bind, and why can the same import statement compiled to CommonJS by TypeScript or Babel end up binding `undefined` instead?

level: middleimportance: must knowfreq 68%

basics

~20 s

Node's ESM loader exposes a CommonJS module's entire module.exports value as its default export, so the import binds the function. A compiler instead rewrites the import to read a default property off the required object, which does not exist unless an interop helper wraps it.

open as a page

In a CommonJS file, `const pkg = require('some-package')` returns an object shaped like `{ __esModule: true, default: [Function] }`, and calling `pkg()` throws `TypeError: pkg is not a function`. What produced that shape, and what does it tell you about how the package was built?

level: juniorimportance: should knowfreq 55%

basics

~20 s

The package was written as ES modules and compiled down to CommonJS. Compilers put an ESM default export on the exports object under a default property and add an __esModule marker, so the callable value is pkg.default, not pkg.

open as a page

A CommonJS file counter.js contains `let count = 0; function increment() { count++; } module.exports = { count, increment };`. A consumer does `const { count, increment } = require('./counter')`, calls increment() twice, then logs count — and gets 0. Why, and how would you expose a value that actually tracks?

level: middleimportance: should knowfreq 52%

basics

~20 s

The exported object stored a copy of the number at the moment the module ran, so its count property has no link to the module's variable, and destructuring copies that stale value again. Expose an accessor or a getCount() function instead.

open as a page

A JavaScript library publishes both a CommonJS build and an ES module build of the same source. What is the "dual package hazard", and how can one Node.js process end up running that library's code twice?

level: middleimportance: should knowfreq 38%

basics

~20 s

One library ends up loaded twice in a process — once from its CommonJS build, once from its ESM build — because require and import resolve the same package name to two different files. Each copy gets its own module state and its own class identities.

open as a page

Transpiled CommonJS output often begins with `Object.defineProperty(exports, "__esModule", { value: true })`. What is that marker for, and how do interop helpers such as Babel's `_interopRequireDefault` or the `__importDefault` helper emitted by TypeScript use it?

level: middleimportance: should knowfreq 42%

basics

~20 s

It is a tooling convention marking a CommonJS exports object as lowered ES module output, so its default property is a real default export. Interop helpers read it to decide whether to pass the object through or wrap it as { default: value }.

open as a page

A CommonJS service takes several seconds to start serving traffic, and the time is spent in top-level code of required modules — parsing config, building lookup tables — before the server ever starts listening. Why does require() put that cost exactly there, and how would you restructure the modules?

level: seniorimportance: should knowfreq 36%

basics

~20 s

require() runs a module's body to completion synchronously before returning, so anything at a module's top level executes during the require call and blocks the single thread. Move expensive work into exported init or factory functions the application calls deliberately.

open as a page

In a Node service, `err instanceof ValidationError` returns false for errors thrown by the very library that exports `ValidationError`, even though `err.constructor.name` is "ValidationError" and only one version of the library is installed. Parts of the codebase load the library with `import`, others with `require`. What is happening, and how would you confirm it?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Two builds of the same library are loaded, so two distinct ValidationError constructors exist. The error came from one copy and is being tested against the other copy's class, and instanceof compares prototype identity, not names. Confirm it by comparing the file paths each side actually resolved.

open as a page

You maintain a JavaScript package that ships both a CommonJS and an ESM build and keeps a module-level registry of plugins. What can you change so consumers can never end up with two independent registries, and what does each option cost them?

level: seniorimportance: should knowfreq 24%

basics

~20 s

Guarantee a single implementation. Either ship one build and make the other entry a thin wrapper that re-exports it, move all stateful pieces into one internal file both builds load, or publish a single format. Each trades bundle quality or consumer convenience for guaranteed single-instance state.

open as a page

In Node's native ESM, `import { parse } from 'legacy-cjs-pkg'` fails with `SyntaxError: The requested module 'legacy-cjs-pkg' does not provide an export named 'parse'`, yet `require('legacy-cjs-pkg').parse` is a function at runtime. Explain what causes that mismatch and how you would fix the import site.

level: seniorimportance: should knowfreq 40%

basics

~20 s

Named exports from a CommonJS module are discovered by statically scanning its source before it runs, so properties attached dynamically are invisible to the linker even though they exist at runtime. Default-import the module and destructure the property afterwards.

open as a page

As the maintainer of a widely depended-on JavaScript library, how would you decide whether to ship an ESM build only, a CommonJS build only, or both, given the dual package hazard?

level: principalimportance: nice to knowfreq 16%

basics

~20 s

Decide by what your public API depends on. If correctness rests on shared state or class identity, ship a single implementation so two copies are impossible. If the library is stateless, dual builds are a bundle-size convenience with no correctness risk, and the choice becomes purely a compatibility question.

open as a page