CommonJS modules and your bundle size

CommonJS, a module standard from 2009, was designed primarily for server-side JavaScript. It lets you define modules, export functionality, and import it elsewhere — for example, a utils.js module that exports add, subtract, multiply, divide, and max, and an index.js that imports just add.

Because browsers lacked a standardized module system in the early 2010s, CommonJS became a popular format for client-side libraries too. But while bundle size is less critical for server-side apps, it remains the number one factor in browser app performance. That gap matters: CommonJS was never built with production bundle size in mind, yet bundlers and minifiers like webpack and terser now rely on static analysis to strip unused code.

The size gap in practice

Consider a webpack build with production optimizations using index.js as the entry point. If utils.js is authored in CommonJS and imports from lodash, the output bundle can be around 625KB — even when index.js only uses add. The final bundle contains all functions from utils.js plus an entire lodash dependency that is never referenced.

Switch the same modules to ECMAScript module syntax, keep the identical webpack configuration, and the output drops to about 40 bytes. No unused functions from utils.js remain, no trace of lodash exists, and terser inlines the used add function directly into the console.log call.

This is a contrived example — a 16,000x difference — but the underlying issue scales. Importantly, using ES module syntax in your entry point does not help if the modules you consume are CommonJS; the optimization failure follows the dependency, not your own source.

Why CommonJS defeats optimization

webpack's ModuleConcatenationPlugin concatenates all module scopes into one closure, which speeds up execution in the browser. With ES modules, a minifier can then remove unused functions, strip comments and whitespace, and inline the body of add into the console.log call. This tree-shaking works because webpack can statically determine, at build time, which symbols are imported and exported.

The same example rewritten with CommonJS produces a very different output. The bundle includes injected webpack runtime code for importing and exporting, and it calls __webpack_require__ at runtime to fetch the add function. This is unavoidable: CommonJS permits dynamic constructs that ES modules forbid. For instance, an export name can be derived from an arbitrary expression:

const name = readFromSomewhere();
module.exports[name] = someValue;

No bundler can know the exported symbol name at build time because that information only exists at runtime, in the user's browser. Consequently, the minifier cannot determine what index.js consumes from its dependencies and cannot tree-shake them away. The same limitation applies to third-party CommonJS modules in node_modules — your build toolchain simply cannot optimize them properly.

Partial workarounds exist

CommonJS modules are harder to analyze because they are dynamic by definition. ES module import locations are always string literals; CommonJS import expressions are not. A third-party webpack plugin can sometimes remove unused CommonJS exports if the library follows specific conventions, but it does not cover all usage patterns and offers nowhere near the guarantees of ES modules. It also adds build-time overhead on top of default behavior.

Conclusion and practical steps

To let your bundler fully optimize your application, avoid depending on CommonJS modules and use ECMAScript module syntax throughout.

  • With Rollup.js, the node-resolve plugin's modulesOnly flag restricts dependencies to ECMAScript modules.
  • The is-esm package verifies whether an npm package uses ES modules.
  • Angular projects show a warning by default when depending on non-tree-shakeable modules.