Consequences of Mutating CommonJS Exports
In CommonJS (CJS) JavaScript environments like Node.js, mutating
exports or module.exports directly can
introduce subtle, hard-to-trace bugs across an application. Because
CommonJS handles exports via object references and caches module
evaluations, improper mutation can silently break the link between
exports and module.exports, fail to propagate
updated values to consumers, cause race conditions in circular
dependencies, and pollute global module caches. Understanding how
CommonJS passes values and manages module objects is essential to
avoiding these pitfalls.
Severing the
exports Alias Reference
At the start of every CommonJS module execution, Node.js provides a
shorthand variable named exports that points to the same
object in memory as module.exports:
// Internal setup before module runs
var module = { exports: {} };
var exports = module.exports;When you reassign exports directly (e.g.,
exports = { foo: 'bar' }), you overwrite the local variable
rather than modifying the actual object that Node.js returns to
consumers. The require() function always returns
module.exports. Reassigning exports severs
this reference, causing the module to export an empty object
({}) instead of your intended interface.
To change the root export object, you must always assign directly to
module.exports:
// Incorrect: Returns {} to the consumer
exports = function() { ... };
// Correct: Returns the function
module.exports = function() { ... };Lack of Live Bindings for Primitives
Unlike ECMAScript Modules (ESM), which use live bindings, CommonJS exports values by copy when dealing with primitives (strings, numbers, booleans).
If a module exports a primitive variable and later mutates its internal copy, the imported value in other files remains unchanged:
// counter.js
let count = 0;
function increment() { count++; }
module.exports = { count, increment };
// app.js
const { count, increment } = require('./counter');
increment();
console.log(count); // Still prints 0Because count was evaluated and copied onto the export
object at the time require() was called, internal mutations
do not update the consumer’s variable.
Cache Pollution and Shared State Bugs
When a module exports an object, array, or class instance, any consumer that modifies properties on that exported object will mutate the cached instance in memory.
Node.js caches the result of require() in
require.cache. As a consequence: - Global side
effects: A mutation made by one file instantly affects every
other file that imports the same module. - Test suite
leakage: Unit tests that mutate imported module properties can
bleed state into subsequent tests, creating non-deterministic test
failures. - Race conditions: Asynchronous operations
modifying shared export properties concurrently can lead to
unpredictable application states.
Circular Dependency Failures
CommonJS resolves circular dependencies by returning the partially
evaluated module.exports object when an unresolved loop is
encountered.
If Module A requires Module B, and Module B requires Module A before
Module A has finished setting up its exports, Module B receives an
incomplete object. If you mutate or overwrite
module.exports later in the execution cycle, the previously
resolved references in Module B will not point to the new exports,
resulting in undefined errors at runtime.
Best Practices to Avoid Mutation Issues
To maintain predictable behavior in CommonJS modules:
- Treat exports as immutable: Avoid modifying properties on imported modules from consumer files.
- Use functions or getter methods: If a module needs
to expose dynamic or updating state, export a function (e.g.,
getCount()) or an object getter rather than a raw primitive. - Explicitly assign to
module.exports: When replacing the export value entirely, always targetmodule.exportsto prevent breaking the shorthand reference. - Consider migrating to ES Modules: ESM native live bindings and strict read-only imports eliminate many reference-mutation issues inherent to CommonJS.