Why can a build tool usually not eliminate unused exports from a CommonJS module the way it can from an ES module?
answer
- when does the interface exist?
- require is a call, not a declaration
- exports is a plain mutable object
- computed reads defeat reachability
- best-effort only for simple shapes
basics
~20 sCommonJS builds its exports at runtime: require is an ordinary function call and module.exports is a mutable object whose properties can be added conditionally and read with computed keys. The export set is not knowable before execution, so tools keep the whole module.
solid answer
~50 sES modules have a static shape — `import`/`export` are declarations, allowed only at the top level, with literal specifiers — so the full set of imports and exports is known after parsing, before anything runs. CommonJS has no such shape. `require()` is a function call that can sit in a branch, a loop, or take a computed specifier, and `module.exports` is a plain object that can be assigned property by property, reassigned wholesale, built in a loop, or even mutated by another module after the fact. Consumers can read it dynamically too, with `lib[name]()`. Nothing tells a build tool which exports exist or which are used without executing the code, so it takes the safe route and includes the whole module, leaving only unreachable statements inside it to the minifier. Modern bundlers do best-effort analysis of simple shapes like top-level `exports.foo = ...`, but the practical answer for a library is to publish an ESM build.
code
javascript · 15 lines// formatters.cjs — the export set only exists after this body runs
module.exports.invoiceHeader = (d) => d.toISOString();
if (process.env.LEGACY === '1') {
module.exports.auditLine = (d) => `AUDIT ${d.toISOString()}`;
}
for (const code of ['gbp', 'eur', 'usd']) {
module.exports[code + 'Formatter'] = (n) => `${code}:${n}`;
}
// consumer.cjs — which export is used is unknowable before execution
const lib = require('./formatters.cjs');
const chosen = lib[process.argv[2] + 'Formatter'];
console.log(chosen(42));go deeper
Know that ESM's import/export are declarations a tool can read before running anything, while CommonJS builds its exports object while the code runs, so unused parts cannot be identified in advance.
Give the concrete mechanisms: conditional or looped exports.x = ..., reassignment of module.exports, computed reads like lib[name], and require calls with non-literal specifiers — and explain why each one collapses the analysis.
Show the diagnosis and the fix: identifying a large CJS dependency as the reason a bundle will not shrink, and deciding between publishing an ESM build, switching to a shakeable alternative, or accepting the cost with evidence.
Own the dependency policy: whether new dependencies must ship ESM, how the org handles the mixed period while CJS-only packages persist, and what the bundle-size budget actually buys relative to the migration cost.
## Static shape versus runtime construction The entire difference comes down to when the module's interface exists. In ESM, `import` and `export` are declarations with syntactic restrictions that exist precisely to make analysis possible: they may appear only at the top level of a module, never inside a function or a conditional, and the specifier must be a string literal. Consequently, after parsing a file the tool knows every name it imports, every name it exports, and every module it depends on — without running a line. ```js import { formatDate } from './format.js'; export function invoiceHeader(d) { return formatDate(d); } export function auditLine(d) { return formatDate(d); } ``` The export set is `{invoiceHeader, auditLine}`, fixed. Reachability analysis over those names is straightforward, so an unused `auditLine` can be dropped. In CommonJS, the interface is *whatever the object happens to look like after the module body runs*: ```js module.exports.invoiceHeader = function (d) { /* ... */ }; if (process.env.LEGACY === '1') { module.exports.auditLine = function (d) { /* ... */ }; } for (const name of ['gbp', 'eur', 'usd']) { module.exports[name + 'Formatter'] = makeFormatter(name); } ``` A tool asked "which names does this module export?" has no answer short of executing the code with the right environment. And execution is not available at build time in general — it depends on env vars, filesystem, network, platform. ## The consumer side is equally dynamic Even a statically shaped CJS module is consumed dynamically: ```js const lib = require('./formatters.js'); const fn = lib[userChoice]; // which export is used? unknowable Object.keys(lib).forEach(register); // all of them module.exports = lib; // re-exported wholesale ``` Because `require` returns an ordinary object, every property is potentially reachable. Reachability analysis over object property access with computed keys degenerates immediately, so the tool must assume every export is used. ## `require` itself is a call, not a declaration ```js if (needsHeavyPath) { const heavy = require('./heavy.js'); // conditional dependency } const mod = require(base + '/' + name); // computed specifier ``` So even the dependency *graph* is uncertain. In ESM, `import()` exists precisely to express a dynamic dependency, and it is visibly distinct from the static form; in CJS every dependency looks dynamic because they all use the same mechanism. ## Other properties that get in the way CJS module scope is not strict by default, so code may use constructs (such as `with`, or implicit globals from an assignment to an undeclared name) that make identifier resolution non-local. And the exports object identity matters: code can hold a reference to `module.exports` and mutate it later, or a consumer can mutate a dependency's exports, both of which are observable and neither of which is expressible in ESM, where exported bindings are read-only from the importer's side. ## What tools do in practice Bundlers are not helpless. Several detect the common, well-behaved shape — a module whose top level does nothing but `exports.name = ...` or a single `module.exports = { ... }` object literal, with no conditional writes and no dynamic reads at the call sites — and can eliminate unused members of it. Treat that as an optimisation that sometimes fires, not a guarantee. The moment a conditional assignment, a loop, a computed read, or a re-export through another CJS layer appears, the analysis backs off to including everything. Minification still removes syntactically unreachable code inside the retained module, but that is a much weaker knife than dropping exports. ## What this means for a package author If you want consumers to shake your library: - Publish an ESM build, with a CJS build alongside if you must support older consumers. Keep the two builds consistent. - Keep exports as top-level declarations, not runtime assignments. - Avoid re-exporting whole namespaces as objects that consumers index into. - Declare `sideEffects` honestly so the ESM build can actually be skipped where unused. And for a consumer diagnosing a fat bundle: a large CJS dependency in the graph is a prime suspect, because its whole surface is in the output regardless of how narrowly you imported from it.
- Bundlers do sometimes shake CommonJS. What has to be true for that to work?The module's exports must be assigned in a simple, unconditional, top-level shape — `exports.name = ...` statements or one `module.exports = { ... }` object literal — with no conditional or looped assignment and no later mutation. Consumers must read those exports with static property access rather than computed keys, and must not pass the exports object around. Treat it as an optimisation that may fire, not a contract.
- How does dynamic `import()` differ from `require()` for analysis purposes?Both are runtime operations returning a module, but `import()` is syntactically distinguishable from static `import` declarations, so a tool can treat the static ones as the reliable graph and the dynamic ones as deliberate split points. In CommonJS every dependency uses the same call form, so nothing separates the analysable dependencies from the genuinely dynamic ones.
- What single change to a published library most improves how well consumers can shake it?Ship an ES-module build. That alone gives consumers a statically analysable export surface, which is the precondition for everything else. Following it up with honest `sideEffects` metadata and keeping module bodies free of import-time work turns the possibility into an actual reduction; without the ESM build neither of those helps much.
saying these in an interview costs you the question
- Says CommonJS is simply an older, slower format
- Thinks a bundler can just run the module to learn its exports
- Believes minification alone removes unused CJS exports
- Assumes `exports.foo = ...` is as analysable as `export function foo`
- Ignores that consumers read CJS exports with computed keys