Modern Module Formats: ESM vs CommonJS
This guide sits under the broader JavaScript bundle optimization and code splitting discipline and focuses on the one decision that gates almost every downstream optimization: the module format your code and your dependencies actually ship in.
The transition from CommonJS (CJS) to ECMAScript Modules (ESM) is not cosmetic. CJS evaluates modules synchronously at runtime, wrapping exports in a function closure that executes the moment require() is called. That dynamic resolution is opaque to bundlers, so unused exports survive into the final payload. ESM, by contrast, declares import and export statically at the top level, letting the bundler build a complete dependency graph before a single line runs. That static graph is the prerequisite for tree shaking, scope hoisting, and parallel fetching. The practical payoff is concrete: a clean ESM graph typically trims initial parse and compile time by 15–30% in modern V8, and it routinely takes 20–40% off the bytes that reach the wire once dead code is eliminated. The targets we hold throughout this page are an initial JavaScript payload under 150KB gzipped, main-thread parse and compile under 100ms, and no single module-evaluation long task over the 50ms budget.
Problem framing: where format choice degrades your metrics
Module format selection propagates directly into your field metrics. CJS modules introduce synchronous blocking: a large dependency tree evaluated behind a single require() chain inflates Total Blocking Time and can push the slowest interaction past the 200ms boundary that defines a good Interaction to Next Paint score. Worse, because the bundler cannot statically prove which exports are reachable, it keeps them, and your initial chunk grows past the 150KB gzip budget even when only a fraction of the code runs. The symptom in Webpack bundle analysis is a vendor chunk that looks far larger than the surface area your app actually touches: whole utility libraries pulled in for a single helper because a CJS entry point exposes a flat module.exports object that defeats shaking.
The diagnostic discipline is the same one used across this site: establish a baseline, isolate the dominant bottleneck, apply one targeted fix, then validate against a budget. The workflow below walks that loop specifically for module-format problems.
There is a second, subtler degradation that format choice causes: it determines how cacheable your output is. Scope hoisting and aggressive concatenation, which ESM enables, produce fewer but larger chunks. That is good for evaluation speed but can hurt long-term cache hit rate, because a single source edit invalidates a larger concatenated unit. CJS, by retaining per-module closures, gives finer cache granularity at the cost of slower evaluation. Neither is universally correct — the right answer depends on your deploy cadence and how the asset is served, which is why the validation step at the end measures the field outcome rather than trusting a static rule of thumb. The point of naming both effects up front is that a module-format migration is never only about bytes; it shifts where time is spent across the fetch, parse, instantiate, and evaluate phases, and a careful engineer measures all four before declaring a win.
It also matters that format problems rarely live in your own source. By the time an app is large enough to feel a parse-time problem, the dominant cost is almost always in transitive dependencies you do not control. That changes the workflow: instead of rewriting your imports, you spend most of your effort auditing what your dependencies ship and steering the resolver toward the analyzable build. The steps below reflect that reality — step 3 is deliberately about reading other people's package.json files, because that is where the leverage is.
Prerequisites: versions, packages, and flags
Pin these before you start, because format behavior shifts meaningfully across versions:
- Node.js 18+ for stable ESM in production (
v14/v16only had it behind flags or with rough edges). Node resolves.mjsas ESM and.cjsas CJS unconditionally, and treats bare.jsaccording to the nearestpackage.json"type". - A bundler with static ESM support: Webpack 5+, Rollup 3+, Vite 5+, or esbuild 0.19+. Older Webpack 4 tree shaking is weaker and ignores nested
sideEffectscorrectly far less often. "sideEffects"declared in thepackage.jsonof every package you author. Without it the bundler must assume any import can mutate global state and refuses to drop it.- An analyzer:
webpack-bundle-analyzer,rollup-plugin-visualizer, orvite-bundle-visualizerto read the before/after.
1. Environment setup
Force a single, predictable interpretation so you are not debugging format ambiguity on top of a performance problem. Set the package type explicitly and let extensions override per file.
{
"name": "@org/app",
"type": "module",
"sideEffects": ["*.css", "./src/polyfills.js"]
}
// trade-off: "type": "module" makes every bare .js ESM. If you still have CJS
// build scripts or config files, rename them to .cjs explicitly — do NOT set
// "type": "module" project-wide while a large legacy CJS surface still uses
// bare .js require() calls, or you'll spend the migration firefighting
// ERR_REQUIRE_ESM instead of measuring bundle size.
Point your bundler's resolver at the ESM entry first so dependencies that ship both formats are read in their analyzable form. The ordering of resolution fields is load-bearing: a resolver that reads main before module will silently pull the CJS build of a dual-format package even though an ESM build sits right next to it, and you will see no error — only a treemap that stays stubbornly large. In Webpack this is resolve.mainFields; in Rollup it is the @rollup/plugin-node-resolve mainFields option; in Vite the dev server reads native ESM but production resolution flows through Rollup, so the same ordering applies. Make this explicit rather than relying on defaults, because defaults have changed across major versions and a default you inherited two upgrades ago may not be the one you assume.
One more setup decision pays off later: decide now whether you are targeting a nomodule fallback at all. Shipping a parallel CJS/ES5 bundle for legacy browsers doubles your build matrix and your cache surface. For an audience of modern frontend engineers on evergreen browsers, the honest answer is usually that the legacy bundle costs more than it earns, and dropping it removes an entire class of dual-format complexity. If your analytics show a meaningful tail of old Safari or legacy Android WebView, keep the fallback but isolate it behind <script type="module"> / <script nomodule> so the modern path never pays for it.
2. Capture a baseline
Never optimize a format you have not measured. Record three numbers before touching anything: gzipped initial chunk size, main-thread parse/compile time, and the slowest module-evaluation task.
// Baseline: log long tasks during module evaluation on the real page.
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
// 50ms is the long-task budget; anything above blocks interaction.
console.log(`long task ${entry.duration.toFixed(0)}ms`, entry.name);
}
}
});
obs.observe({ type: 'longtask', buffered: true });
// trade-off: longtask only fires on the main thread and gives no stack. Use it
// to confirm a problem exists, then switch to the DevTools Performance panel for
// attribution — don't try to diagnose root cause from this beacon alone.
Run your analyzer and screenshot the treemap. The modules you suspect are CJS will usually be the flat, undifferentiated blocks that resist splitting — a single large rectangle where you expected a grid of small named exports. Save that image; the before/after treemap comparison is the most persuasive evidence you can bring to a code review, far more so than a single byte count, because it shows where the weight moved.
Record the baseline numbers somewhere durable, not in your terminal scrollback. The three that matter are gzipped initial-chunk size, total main-thread parse/compile time from the Performance panel's bottom-up "Compile Script" entries, and the duration of the single longest module-evaluation task. Write them down with the date and the commit SHA. A migration that "feels faster" but cannot show a delta against a recorded baseline is indistinguishable from a placebo, and it will not survive the next person who asks whether the added build complexity was worth it. The discipline of a written baseline is also what lets you attribute a later regression to a specific change rather than re-auditing the whole graph from scratch.
3. Isolate the bottleneck
With a baseline in hand, find which modules are forcing CJS behavior. Two commands do most of the work:
npm ls <pkg>(oryarn why <pkg>) shows every path that pulls a dependency in, exposing duplicate copies bundled under different formats — the dual-package hazard.- Reading the dependency's
package.jsonexports/main/modulefields tells you whether the bundler even has an ESM entry to choose. A package with only"main": "./index.js"and no"module"or"exports.import"is CJS-only and will not tree-shake no matter how your app is configured.
Circular dependencies surface here too. In CJS a circular require() returns a partially-evaluated module.exports, so you get undefined at access time with no error. ESM's live bindings and temporal dead zone fail fast instead, which is exactly why migrating often reveals latent bugs rather than causing them. Treat a wave of Cannot access before initialization errors during migration as a feature, not a regression: each one is a real circular dependency that CJS was papering over, and fixing it usually shrinks the graph because circular references force the bundler to keep modules in the same chunk.
A practical isolation tactic is to bisect by format rather than by file. Temporarily flip your resolver's mainFields order back to main-first and re-measure: the size and parse delta between the two configurations is, almost exactly, the cost your CJS dependencies are imposing. If flipping the order barely changes the numbers, your problem is not format at all — it is genuinely-used code, and you should redirect your effort to splitting or removing features rather than chasing module formats. This single experiment saves hours, because it tells you within one build whether this page's whole premise even applies to your codebase. If it does not, stop here: the most expensive optimization is the one that fixes a bottleneck you do not have.
Pay special attention to libraries that re-export everything from a barrel file. An index.js that does export * from './a'; export * from './b' looks like ESM but, if any of those sub-modules are CJS or carry side effects, the bundler conservatively retains the entire surface. Barrel files are the single most common reason a nominally-ESM dependency fails to shake, and the analyzer treemap is where you spot them: the whole library appears even though your code touches one helper.
4. Apply the fix
Rank fixes by payload impact. The biggest wins come from (a) replacing a CJS-only dependency with its ESM build (lodash → lodash-es, moment → dayjs), (b) declaring sideEffects so the bundler can drop untouched modules, and (c) enabling scope hoisting (concatenateModules) so the runtime stops wrapping every module in its own closure. For dependencies that genuinely have no ESM build, let the bundler pre-bundle them into an ESM-compatible shim rather than scattering require() through your source. When the heavy code is genuinely optional, move it behind a boundary using dynamic imports and route-based splitting so it never lands in the initial chunk at all, and pair the work with tree shaking and dead-code elimination to strip what survives.
// webpack.config.js — ESM-first resolution + shaking + scope hoisting
module.exports = {
mode: 'production',
resolve: {
// Prefer the analyzable ESM entry over the CJS "main".
mainFields: ['module', 'main'],
extensions: ['.mjs', '.js', '.json'],
},
optimization: {
usedExports: true,
sideEffects: true, // honor each package.json "sideEffects"
concatenateModules: true, // scope hoisting: fewer closures, faster eval
splitChunks: { chunks: 'all' },
},
};
// trade-off: concatenateModules increases build time and can defeat per-module
// caching granularity — a one-line change in a hoisted module invalidates the
// whole concatenated chunk. Disable it for very large, frequently-edited vendor
// bundles where long-term cache hit rate matters more than eval speed.
Deconstructing module load into timing phases
Treat a module's cost as four measurable phases, each with its own budget and its own fix. Optimizing the wrong phase wastes effort.
| Phase | What happens | Budget | Dominant fix |
|---|---|---|---|
| Fetch | Network download of the chunk | governed by cache headers | splitting + immutable caching |
| Parse | Bytecode generation in V8 | < 100ms total | smaller payload via shaking |
| Instantiate | Wiring import/export bindings | < 15ms per chunk | scope hoisting, fewer modules |
| Evaluate | Top-level code runs | < 50ms per task | defer side-effectful work |
The decisive insight: CJS collapses instantiate and evaluate into one synchronous step at require() time, so a deep CJS tree spends its entire cost on the main thread the instant it is referenced. ESM separates instantiation (cheap, structural) from evaluation, and lets the fetch and parse phases overlap across the graph. That separation is why the same logical dependency tree produces a lower Total Blocking Time under ESM.
Walk a concrete example through the table. Suppose a dashboard imports a charting library that, in turn, requires a date library and a locale data file, all CJS. Under CJS, the moment your route code calls require('charts'), the runtime synchronously fetches nothing (it is already bundled) but evaluates the entire chain top to bottom on the main thread: locale tables parse, the date library builds its formatter cache, the chart library registers its component classes — one unbroken task that the profiler shows as a single fat block right before your first paint. Under ESM with scope hoisting, the same code is parsed as one concatenated unit during the parse phase, instantiation just wires the bindings, and only the genuinely side-effectful top-level statements run during evaluation. The locale data, if it is only referenced inside a function, may be tree-shaken away entirely. The net effect is that the work either disappears or spreads across phases the browser can interleave with paint, instead of landing as one blocking task.
The phase model also explains why "it parses faster" and "it is interactive faster" are different claims. Parse time lives in the parse phase and is dominated by total bytes; you reduce it by shipping fewer bytes. Interactivity is gated by the evaluate phase and by long tasks; you reduce it by deferring side-effectful work and by keeping any single evaluation under the 50ms long-task budget. A migration can win one and lose the other — for instance, concatenating everything into one chunk cuts instantiation overhead but can create a single large evaluation task. That is precisely why the budget you enforce in CI must include a long-task or Total Blocking Time assertion, not only a byte ceiling.
Advanced diagnostics: framework and edge-case failure modes
- Dual-package hazard. A dependency imported as ESM by your app and as CJS by a transitive dependency gets instantiated twice with separate internal state. Singletons (a client, a store, a context) silently fork. Detect it with
npm lsshowing two versions, and fix it with an explicitexportsmap or a resolveraliasthat collapses both to one build. interopDefaultconfusion. Dynamicimport()of a CJS module resolves to a namespace where the CJSmodule.exportslands on.default. Code that expected named exports getsundefined. Configure your bundler's interop or access.defaultexplicitly.- iOS Safari / JavaScriptCore. Large ESM graphs validate strictly and can spike memory during initial compile on JavaScriptCore. Precompile into a small number of static chunks rather than shipping a deeply nested dynamic-import tree to old Safari.
- Server processes. In long-lived Node servers, watch for orphaned module caches retaining memory. ESM's immutable bindings reduce GC pressure versus mutable
module.exports, but acreateRequirebridge used carelessly can pin CJS modules in memory. require('esm-only-package')in legacy code. A CJS file that tries to synchronously require an ESM-only dependency throwsERR_REQUIRE_ESM. The fix is either to convert the requiring file to ESM or, in Node 22+, to rely on the new synchronous-ESM-require support — but do not assume that support exists on your deploy target; check the runtime version first.- Conditional exports that omit a condition. A dependency whose
exportsmap definesimportandrequirebut forgetsdefaultwill resolve correctly in Node and break in a bundler that probes fordefault, or vice versa. When a dependency works locally and fails in CI, mismatched export conditions between the two resolvers is a prime suspect.
Each of these failure modes is recoverable, but they share a lesson: format problems hide behind silence. A dual-package hazard does not throw; a barrel file that defeats shaking does not warn; a main-first resolver order produces no diagnostic. The treemap and the long-task beacon are your only reliable signal, which is why the measurement steps are not optional ceremony but the actual substance of the work.
Validation and budgeting
Re-run the analyzer and the long-task beacon from step 2 and compare. A successful migration shows the vendor treemap shrinking as flat CJS blocks split into shakable modules, the gzipped initial chunk back under 150KB, and the longest module-evaluation task under 50ms. Lock the win in CI so it cannot regress.
# .github/workflows/bundle-budget.yml — fail the build if the budget is breached
- name: Enforce bundle budget
run: npx bundlesize
# bundlesize.config reads:
# { "path": "dist/assets/index-*.js", "maxSize": "150 kB", "compression": "gzip" }
# trade-off: a single hard ceiling can block a legitimate, well-justified feature
# that grows the bundle. For mature apps prefer a percentage-delta gate (warn at
# +5%, fail at +10%) so intentional growth is reviewed rather than silently blocked.
Finally, confirm in the field, not just the lab: watch p75 INP and the long-task rate in your RUM data for a few days after deploy. Lab parse-time wins that do not move the field p75 usually mean the bottleneck was elsewhere.
Budget the right metric for the right phase. A byte ceiling protects the fetch and parse phases but says nothing about evaluation, so pair it with a Total Blocking Time or long-task assertion that protects interactivity. If you concatenated aggressively to win evaluation speed, add a cache-hit-rate check from your CDN logs to your deploy review so the long-term cost of coarser chunks does not creep up unnoticed. The cleanest posture is three gates that map to the three risks: a gzip ceiling for payload, a TBT ceiling for interactivity, and a cache-hit-rate floor for delivery. Together they make a format regression impossible to merge silently, which is the entire goal — the migration is only durable if the budget that justified it lives in CI rather than in someone's memory of the day they did the work.
Keep the recorded baseline alongside these gates. Six months from now, when a dependency upgrade quietly reintroduces a CJS build or a new barrel import re-inflates the vendor chunk, the gate fails and the baseline tells you exactly what "good" looked like. That closes the diagnostic loop the whole page is built around: baseline, isolate, fix, and a budget that holds the fix in place.
Related
- JavaScript bundle optimization and code splitting — the parent discipline that frames payload, splitting, and delivery as one system.
- Tree shaking and dead-code elimination — the optimization that ESM's static graph makes possible.
- Dynamic imports and route-based splitting — move optional code out of the initial chunk entirely.
- Webpack bundle analysis techniques — read the treemap to spot CJS modules that block shaking.
- Deferring non-critical analytics scripts safely — apply these format rules to third-party SDKs.