A module system either hands the bundler a graph it can read before running the program — or forces it to execute the code to find out. That single difference is the whole story of tree shaking.
A bundler wants to turn many modules into the smallest correct output. To do that it must answer one question: can it know the dependency graph and which exports are used without running the program?
That is the entire difference between a static module system and a dynamic one. In ESM, imports and exports are part of the language grammar, so the graph is visible before any code executes. In CommonJS, require is an ordinary function call that runs during execution and can appear anywhere — which means the bundler often has to guess, and a safe guess keeps more code than it needs. Flip the graph above to watch the same modules either tree-shake cleanly or balloon.
| Question | ESM | CommonJS |
|---|---|---|
| Import form | import syntax | require() function |
| When discovered | Before execution | During execution |
| Module graph | Static | Runtime / discovered as run |
| Export shape | Static named bindings | Mutable exports object |
| Loading style | Async-friendly / link phase | Synchronous require |
| Tree shaking | Strong | Weaker / heuristic-based |
| Dynamic loading | import() | require(expression) |
| Bundler confidence | High | Lower |
Here are my dependencies. Here are my exports. Here are the exact bindings I use — all readable before a single line executes.
While I run I may call require(), build exports, choose modules, mutate objects, and reach for properties by a runtime string.
That is why ESM is friendlier to static analysis, tree shaking, chunking, and dead-code elimination. CommonJS can still be bundled — but once require() or export access becomes dynamic, the bundler loses its proof and has to keep more code to stay correct.