Static structure → provable graph

ESM vs CommonJS, and why bundlers can prove one but not the other

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.

Flip the graph between modes Click any node or card → deep dive Hover underlined terms for a definition Press i bottom-right to reveal what's interactive
2 kept · 6 dropped — graph is provable 8 kept — argument unknown, keep everything
import { a } lib[name]() require("./db.js") require("./"+name) entry.js the program root lib.js · exports a() keep keep b() drop keep c() drop keep ./adapters/ postgres.js keep keep mysql.js drop keep sqlite.js drop keep mock.js drop keep redis.js drop keep
reachable · kept unreachable · dropped (ESM) unknown · kept to be safe (CommonJS)

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.

The eleven ideas

Every concept, one click deep

Side by side

The clean comparison

QuestionESMCommonJS
Import formimport syntaxrequire() function
When discoveredBefore executionDuring execution
Module graphStaticRuntime / discovered as run
Export shapeStatic named bindingsMutable exports object
Loading styleAsync-friendly / link phaseSynchronous require
Tree shakingStrongWeaker / heuristic-based
Dynamic loadingimport()require(expression)
Bundler confidenceHighLower
Lock it in

Two systems, two promises

ESM says

Know me before running me

Here are my dependencies. Here are my exports. Here are the exact bindings I use — all readable before a single line executes.

CommonJS says

Run me to find out

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.

Back to JIT-Atlas