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 0

Because 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: