Background: How Rollup Thinks About Your Graph
Module Graph and Binding-Level Tree-Shaking
Rollup constructs a module graph from entry points and resolves imports using plugins. It tracks symbol usage at the binding level, enabling elimination of dead exports more aggressively than bundlers that only prune at the file level. This precision is why plugin correctness and side-effect semantics matter: an imprecise plugin can mask mutations or inject code that defeats tree-shaking.
Rollup's tree-shaking assumes modules are pure unless it detects obvious side effects or you mark them via configuration. When enterprise code uses reflection, dynamic property access, or mutable global state, the tree-shaker becomes conservative and retains more code than expected.
ESM & CJS Interop Model
Rollup is ESM-first. CommonJS is supported via plugins that transform require() and module.exports into ESM constructs. The transformation is lossy in edge cases: exotic getters, temporal dead zones across boundaries, and dynamic requires complicate the model. Understanding how your packages expose default and named exports helps prevent runtime "undefined" scenarios after bundling.
Code-Splitting & Chunking
With multiple entries or dynamic imports, Rollup emits shared chunks to avoid duplication. Chunk names are heuristic by default; manual control via manualChunks
and chunkFileNames
produces deterministic builds. In enterprise CI networks, determinism keeps artifact caches hot and rollback diffs meaningful.
Enterprise Architecture Implications
Monorepos, Workspaces, and Package Boundaries
Workspaces (e.g., npm, Yarn, PNPM) reduce duplication but blur dependency resolution. Symlinked packages behave like source when hoisted, so your Rollup pipeline may bundle workspaces differently from published artifacts. Aligning exports
, type
, and module
/main
fields across packages is critical to avoid mixed ESM/CJS graphs.
TypeScript Path Aliases
Path aliases (compilerOptions.paths
) are not standard runtime resolution. If your Rollup resolve plugin is unaware of these aliases, you get unresolved imports or accidental duplication when aliases map to the same physical file via different specifiers. Ensure one canonical specifier reaches the bundler.
Dual Packages and Conditional Exports
Modern packages often ship both ESM and CJS under conditional exports. When the graph mixes conditions (node vs. browser vs. default), you can accidentally import different implementations across chunks. This manifests as duplicated singletons, broken instanceof checks, or diverging feature flags. Resolve conditions explicitly for each target build.
Diagnostics: Building a Reproducible Investigation Harness
Enable Structured Logging
Start with verbose logging from resolve and transform phases. Most failures boil down to a wrong file being resolved or a transform that changes semantics. Correlate log lines with plugin order to see who touched what.
// rollup.config.mjs import path from "node:path"; import resolve from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import json from "@rollup/plugin-json"; import typescript from "@rollup/plugin-typescript"; const debug = (name) => ({ name, buildStart() { this.warn(`[${name}] build start`); }, resolveId(source, importer) { this.warn(`[${name}] resolve ${source} <- ${importer ?? "<entry>"}`); return null; // let others handle }, transform(code, id) { this.warn(`[${name}] transform ${id} (${code.length} bytes)`); return null; } }); export default { input: "src/index.ts", output: { dir: "dist", format: "esm", sourcemap: true }, plugins: [debug("trace"), resolve({ preferBuiltins: false }), commonjs(), json(), typescript()] };
Produce a Minimal Failing Graph
Extract the smallest set of files that still reproduces the error. In monorepos, yarn/npm workspaces can mask hoisting bugs, so place the repro in an isolated temporary folder with only the needed deps. Confirm the Rollup version and plugin versions, then freeze them.
Trace Resolution and Conditions
When debugging dual packages, dump which package.json
conditions were matched and which file path was chosen. This is crucial for differences between Node, browser, and "default" conditions.
// debug-resolver.mjs import { createFilter } from "@rollup/pluginutils"; export function conditionTracer() { return { name: "condition-tracer", resolveId(source, importer, opts) { this.warn(`[cond] resolving: ${source} from ${importer ?? "<entry>"}`); return null; } }; }
Understand Common Error Classes
- Unresolved Import: A specifier cannot be found under configured extensions or conditions. Usually alias misconfig or missing plugin.
- Default Export is not a Function: CJS default interop mismatch; named import used where default or namespace is required.
- Cannot Use 'import' Outside a Module: Emitting ESM to a CJS runtime or vice versa.
- Invalid Source Map: Plugin chain produced overlapping or shifted mappings; minifier then compounded it.
- Chunk Emission Conflict: Two assets want the same file name or a plugin emits duplicate names without hashing.
- Side-Effect Detection Miss: Aggressive tree-shaking removed needed initialization; sideEffects metadata missing or wrong.
Deep Pitfalls and Root Causes
Side Effects: The Silent Contract
Tree-shaking depends on side-effect inference. Files that register global singletons, augment prototypes, or initialize DI containers must be annotated as having side effects. In enterprise code, a single accidental mutation can make a whole subtree opaque to pruning.
// package.json in a library { "name": "ui-kit", "sideEffects": [ "polyfills/*", "src/register-icons.js" ] } // For files that are known pure, keep them out of the list.
CJS/ESM Default Interop Ambiguity
A CommonJS module exports via module.exports
. When imported as ESM, Rollup's commonjs plugin can synthesize a default export. If your code uses import * as pkg
and expects pkg.default
, but the CJS module later switches to true ESM with export default
, you can double-wrap and break call sites.
// brittle-consumer.ts import * as lib from "legacy-cjs-lib"; lib.default(); // works only if plugin synthesizes default // robust-consumer.ts import lib from "legacy-cjs-lib"; // or use named members explicitly lib();
Dual Package Hazard: Condition Drift Across Chunks
Consider a dependency that exports different code for "node" and "browser". If one chunk resolves "node" while another resolves "browser" (e.g., due to different browser
fields or plugin config), singletons diverge and side effects execute twice. This can create heisenbugs that appear only on particular import paths.
Path Alias and Duplicate Module Instances
Aliases like @core/utils
and ../../core/utils
may both resolve to the same source but generate two graph nodes. Reactivity frameworks or DI containers will then register two instances. Establish one canonical specifier and teach Rollup's resolver to enforce it.
Source Map Drift Under Multi-Stage Transforms
TypeScript emits a map; Babel emits another; the minifier a third. If any stage drops column mappings or inlines without propagation, your final map points at air. This is disastrous for production error triage. Prefer fewer transform stages or ensure every plugin returns high-fidelity maps.
Step-by-Step Fixes for High-Impact Issues
1) Fixing Unresolved Imports with Path Aliases
Symptoms: Could not resolve '@core/logger' or duplicated modules under different specifiers. Root cause: Rollup's resolver does not know your TS aliases.
// rollup.config.mjs import resolve from "@rollup/plugin-node-resolve"; import alias from "@rollup/plugin-alias"; import typescript from "@rollup/plugin-typescript"; import path from "node:path"; export default { input: "src/index.ts", output: { dir: "dist", format: "esm", sourcemap: true }, plugins: [ alias({ entries: [ { find: "@core", replacement: path.resolve("./packages/core/src") } ] }), resolve({ extensions: [".mjs", ".js", ".ts", ".json"] }), typescript({ tsconfig: "./tsconfig.json" }) ] };
Best practice: Keep TS paths and Rollup alias entries generated from one source of truth to avoid drift.
2) Normalizing CJS/ESM Interop
Symptoms: "default is not a function" at runtime after upgrading a dependency. Root cause: a library moved from CJS to ESM and your import style now mismatches.
// rollup.config.mjs import commonjs from "@rollup/plugin-commonjs"; import resolve from "@rollup/plugin-node-resolve"; export default { input: "src/index.ts", output: { file: "dist/bundle.cjs", format: "cjs", sourcemap: true }, plugins: [resolve(), commonjs({ requireReturnsDefault: "auto" })] }; // consumer code: prefer explicit default import import lib from "some-legacy-lib"; lib();
Mitigation: For highly dynamic CJS, consider requireReturnsDefault: true
to force default interop, then migrate call sites methodically.
3) Controlling Code-Splitting and Deterministic Chunking
Symptoms: Cache misses and noisy diffs on every build. Root cause: chunk names and shared graph change with small code edits, making artifacts unstable.
// rollup.config.mjs import { defineConfig } from "rollup"; export default defineConfig({ input: { app: "src/app.ts", admin: "src/admin.ts" }, output: { dir: "dist", format: "esm", sourcemap: true, entryFileNames: "[name]-[hash].js", chunkFileNames: "chunks/[name]-[hash].js", assetFileNames: "assets/[name]-[hash][extname]" }, manualChunks(id) { if (id.includes("node_modules")) return "vendor"; } });
For larger graphs, use a manifest to lock strategic vendor partitions (e.g., "react", "rxjs", "date-fns") into named chunks. Keep an eye on duplicate copies across conditions.
4) Improving Build Throughput at Scale
Symptoms: Build times increase nonlinearly as the repo grows. Root cause: expensive transforms on unchanged files, non-incremental plugin work, or cold caches.
// rollup.config.mjs import typescript from "@rollup/plugin-typescript"; export default { // Enable Rollup cache across watch runs cache: true, input: "src/index.ts", output: { dir: "dist", format: "esm", sourcemap: true }, plugins: [ typescript({ // speed up: transpileOnly and external typecheck via tsc -b tsconfig: "tsconfig.build.json" }) ], watch: { clearScreen: false, buildDelay: 150 // debounce flapping saves CPU on monorepos } }; // In CI: prewarm dependency caches and enable incremental tsc // tsc -b --incremental --tsBuildInfoFile .cache/tsbuildinfo
Use fewer transformation stages. If TypeScript targets modern syntax, you may not need Babel in the production path. When you do need Babel for polyfills or plugins, ensure source maps chain correctly and only run Babel over changed files.
5) Eliminating Source Map Drift
Symptoms: Errors in production point to wrong lines; stack traces are meaningless. Root cause: broken map composition or minifier misconfiguration.
// rollup.config.mjs import terser from "@rollup/plugin-terser"; export default { input: "src/index.ts", output: { dir: "dist", format: "esm", sourcemap: true, sourcemapExcludeSources: false }, plugins: [ // Ensure each plugin returns a valid map { name: "map-guard", transform(code, id) { const result = null; // no-op // In a real plugin, validate incoming this.getCombinedSourcemap() return result; } }, terser({ format: { comments: false } }) ] };
Validate maps by sampling positions with source-map
tooling during CI. If a plugin drops column mappings, prefer another plugin or run it earlier.
6) Taming "this is undefined" and Strict ESM Semantics
Symptoms: Legacy libraries reference global this
at module top level. In ESM, top-level this
is undefined
.
// rollup.config.mjs import replace from "@rollup/plugin-replace"; export default { plugins: [ replace({ preventAssignment: true, values: { "this": "globalThis" } }) ] };
Prefer targeted shims over blanket replaces. For specific third-party modules, use transform
filters and request upstream fixes after stabilizing locally.
7) Handling Dynamic Imports and Lazy Chunks
Symptoms: Lazy routes fail to load at runtime due to incorrect public paths or asset names. Root cause: asset URL resolution not aligned with your CDN layout.
// rollup.config.mjs export default { output: { dir: "dist", format: "esm", chunkFileNames: "chunks/[name]-[hash].js", assetFileNames: "assets/[name]-[hash][extname]", // if your loader needs an absolute base // set output.paths or use a runtime base URL variable } };
Integrate a runtime loader that prefixes chunk URLs with the correct CDN base (e.g., from window.__ASSET_BASE__
). Keep filename patterns stable, and publish a manifest to the app server.
8) Externalizing Dependencies for Node vs. Browser
Symptoms: Bundles ship large server-only libraries into the browser, or browser shims leak into Node. Root cause: incorrect externals selection and condition matching.
// rollup.config.mjs import { builtinModules } from "node:module"; const externals = [ ...builtinModules, /^node:.*/, /^aws-sdk(\/.*)?$/ ]; export default { external: externals, output: [{ file: "dist/index.cjs", format: "cjs" }, { file: "dist/index.mjs", format: "esm" }] };
Keep externals declarative and test importability at runtime in each target environment. Add smoke tests that require the emitted files under Node and browser conditions.
9) Stabilizing Dual Outputs (CJS & ESM) with Export Maps
Symptoms: Consumers import the wrong entry, or bundlers tree-shake differently across outputs.
// package.json { "name": "data-core", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.mjs", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, "./feature": { "import": "./dist/feature.mjs", "require": "./dist/feature.cjs" } } }
Ensure both files exist and point to equivalent APIs. Contract tests should import via both paths and compare behaviors.
10) Guarding Against Prototype Augmentation & Mutations
Symptoms: After bundling, runtime order changes and a polyfill executes later than before, breaking code that relied on early augmentation.
// Ensure side-effectful imports are first import "./polyfills/array-flat"; import { startApp } from "./app"; startApp();
Express ordering explicitly; do not rely on incidental import trees for initialization. Mark the polyfill files as side-effectful in package.json
.
Performance Engineering for Rollup at Scale
Plugin Order and Minimal Work
Resolve early, transform late. A light resolve stage avoids unnecessary I/O, and only after resolution should expensive transforms run. Memoize expensive computations (e.g., schema generation) and write plugins that respect Rollup's cache to skip unchanged assets.
Parallelization and Process Boundaries
Rollup's core pipeline is largely single-threaded per bundle. Distribute work across packages (monorepo parallelism) rather than forking per-file transforms that create startup overhead. If a plugin offers worker pools, ensure they reuse workers across incremental builds.
Cold Start vs. Hot Path
Measure separately. Cold starts are dominated by disk I/O and dependency resolution; hot paths by transform and hashing costs. Prebuild vendor chunks and cache node_modules transforms at CI layer to amortize costs.
Sourcemap Generation Cost
Source maps can add 20–50% CPU in large graphs. Generate them only for production builds that need them, or use sourcemap: "inline"
during local development to reduce filesystem churn.
Governance: Making Fixes Stick
Contracts as Tests
Write tests that assert bundle shapes (number of files, presence of specific chunks), public exports (no accidental re-exports), and side-effect markers. A failing contract test catches regressions long before consumers feel them.
Versioning and Consumer Guidance
When changing output formats or export maps, treat it as a semver-major event. Publish a migration note and a codemod for common import rewrites to reduce downstream friction.
Security and Supply Chain
Pin plugin versions, enforce lockfiles, and scan transitive dependencies. Because plugins run arbitrary transforms, a compromised plugin can exfiltrate code at build time. Keep an allowlist and review diffs for plugins in dependency updates.
Best Practices Checklist
Configuration Patterns
- Separate configs per target (browser ESM, Node CJS, SSR) with explicit externals and conditions.
- Use
manualChunks
to stabilize vendor partitions. - Keep alias mappings in one canonical source and generate both TS and Rollup config from it.
- Enable source maps only where they serve debugging workflows; verify in CI.
Operational Practices
- Pin Rollup and plugin versions; upgrade intentionally with a changelog review.
- Collect build metrics: time per phase, cache hit rate, and emitted file count.
- Run smoke tests that import emitted files under Node and in a browser sandbox.
- Publish an asset manifest and automate CDN cache invalidation for changed files only.
Documentation and Onboarding
- Document how to add a new entry point, where to put side-effectful imports, and how to mark externals.
- Provide a "bundle doctor" script that reports duplicate modules, large chunks, and invalid maps.
- Record common interop patterns (default vs. named) and keep them consistent in code reviews.
Worked End-to-End Example: From Failing Build to Stable Artifact
Scenario
A dashboard app in a monorepo starts failing with "default is not a function" after a routine dependency update. The app uses aliases and dual outputs (ESM for browsers, CJS for SSR).
Steps
- Freeze versions: Pin Rollup and plugins to the current lockfile to ensure reproducibility.
- Minimal repro: Extract the dashboard package and the one dependency that changed.
- Trace interop: Enable commonjs logs and confirm the dependency flipped from CJS to ESM in the new version.
- Rewrite imports: Convert
import * as lib
toimport lib from "lib"
or named imports per its new API surface. - Dual output test: Import the built ESM in a browser sandbox and the CJS in SSR. Verify parity by asserting results of exported functions.
- Contract test: Add a test that fails if the dependency reverts interop in a future release (guardrail).
// example-contract.test.ts import * as esm from "../dist/index.mjs"; import cjs from "../dist/index.cjs"; test("esm and cjs APIs are equivalent", () => { expect(Object.keys(esm)).toEqual(Object.keys(cjs)); });
Conclusion
Rollup delivers exceptional bundle clarity and dead-code elimination, but enterprise systems surface edge conditions that require architectural thinking, not just configuration tweaks. Anchor your pipeline with deterministic chunking, explicit interop rules, clean aliasing, and enforceable contracts. Build a diagnostic harness that makes resolution decisions and transform effects observable. With those guardrails, upgrades become routine, bundles remain predictable, and incident response shrinks from multi-day archaeology to predictable checklists.
FAQs
1. How do I prevent duplicate vendor copies across code-split chunks?
Define stable manualChunks
for strategic vendors and monitor the bundle graph for duplicates. If conditions differ (browser vs. node), align resolve conditions and mark the module as external for one target to avoid parallel copies.
2. What's the safest way to ship both ESM and CJS without confusing consumers?
Emit two files and control discovery with the exports
map keyed on import
and require
. Add contract tests that import through both paths and ensure the same symbols and side effects appear.
3. Why does tree-shaking fail on my utility library even though everything is "pure"?
Hidden side effects (e.g., getters with work, prototype augmentation, global feature toggles) force conservative retention. Audit modules, annotate side-effectful files in package.json
, and remove incidental mutations from import time.
4. How can I make source maps trustworthy in a multi-plugin pipeline?
Reduce stages, prefer plugins with high-fidelity maps, and validate with a CI sampler that checks representative locations through this.getCombinedSourcemap()
. If a plugin drops columns, run it earlier or replace it to preserve mappings.
5. When should I externalize dependencies vs. bundle them?
Externalize for Node targets where the runtime provides modules or where code size and startup aren't critical. Bundle for browsers to reduce request overhead, but keep heavy optional peers external and lazy-load via dynamic imports for rare paths.