Choosing Rollup Output Formats for a Library: ES, CJS, and Tree-Shaking Decisions
A decision guide for publishing a library with Rollup: why dual es + cjs output is the practical default, how external and sideEffects settings make or break tree-shaking, and how to validate the result.
11 Sept 2026, 18:43 UTC

The decision and its constraints
You're publishing a JavaScript library and need to decide what Rollup should emit. The wrong choice means consumers can't tree-shake your package (their bundles bloat), or Node users can't require it, or a polyfill silently disappears from the build. The useful takeaway: for a new library, emit both es and cjs outputs from one config, author your source in ES module syntax, declare peer dependencies as external, and explicitly verify that side-effect-only modules survive the build.
Three constraints drive everything else:
- Tree-shaking requires ESM. Rollup statically removes unused exports only when the input uses
import/export. If your source is CommonJS, dead-code elimination won't happen. - Consumers differ. Browser bundlers (webpack, Vite, Rollup itself) prefer ES modules. Older Node tooling and some test runners still want CommonJS.
- Side effects are assumed absent. Rollup's default tree-shaking treats modules as side-effect-free, so a file that only registers a polyfill or imports CSS can be dropped if nothing imports its bindings.
Comparing the output formats
| Format | Best for | Tree-shaking by consumers | Code splitting | Main limitation |
|---|---|---|---|---|
es | Modern bundler consumers, browsers via <script type="module"> | Yes | Yes (with output.dir) | Not directly require()-able in older Node |
cjs | Node compatibility, legacy tooling | No | Yes (with output.dir) | Consumers can't tree-shake it |
iife | Direct script-tag delivery with a global | No | No | Single file only; requires output.name |
umd | Legacy dual browser/AMD/CommonJS delivery | No | No | Effectively legacy for new libraries |
The practical default for a publishable library is dual es + cjs: the ES build enables consumer-side tree-shaking, the CJS build keeps Node compatibility. UMD is rarely justified for a new package; let consumers' bundlers handle format conversion. Use iife only when you genuinely ship a standalone script tag, and accept that it disables code splitting.
A concrete dual-output configuration
Rollup accepts an array of output configs, so one build produces both formats. Run rollup -c from the package root (no special permissions needed; it only writes to your dist directory):
// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: 'src/index.js',
external: ['react', 'react-dom'], // peer deps: never inline these
plugins: [resolve(), commonjs()], // order matters: resolve before commonjs
output: [
{ file: 'dist/index.esm.js', format: 'es' },
{ file: 'dist/index.cjs.js', format: 'cjs' },
],
};Two details in this config carry most of the risk:
external: failing to list peer libraries like React inlines them into your artifact, inflating the bundle and duplicating singleton state (two React copies break hooks). Check the emitted file — a peer dependency should appear as animport/requireat the top, not as inlined source.- Plugin order: plugins run in declaration order.
@rollup/plugin-node-resolvemust come before@rollup/plugin-commonjsso CJS dependencies are located before they're converted. Mixing CJS dependencies without the commonjs plugin produces "is not exported by" errors at build time.
Point your package.json at both artifacts so tooling picks the right one:
{
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
}
},
"sideEffects": false
}The side-effects trap
Rollup assumes modules are side-effect-free when deciding whether a dropped import can be removed entirely. If your library has a file like src/polyfill.js that only mutates globals, and a consumer's bundler drops it because nothing imports its bindings, the polyfill vanishes silently — no error, just broken behavior at runtime.
You have two supported levers:
- Set
"sideEffects": falsein package.json only if every module is genuinely pure; otherwise list the impure files:"sideEffects": ["src/polyfill.js", "**/*.css"]. - Within your own Rollup build, use
treeshake.moduleSideEffects(a boolean, or a function receiving the module id) to force retention.
Validating the build
Don't assume tree-shaking engaged — verify it:
- Dead-code check: add an exported function that nothing imports, run
rollup -c, and grep the emitted ES bundle for its name. It should be absent. - Consumer-side check: in a scratch app, import one function from the built ES output, bundle with Rollup or webpack, and confirm unrelated exports don't appear in the consumer's bundle.
- External check: compare bundle size before and after marking a dependency external; the peer library's source should disappear from the artifact.
- Side-effect check: add a deliberate side-effect-only module, build with defaults, and inspect whether it survives. Then set
moduleSideEffectsappropriately and rebuild to compare.
Limitations
Tree-shaking options, output interop defaults, and plugin APIs have changed across Rollup major versions — confirm option names against the version actually installed (npx rollup --version). Named exports from CJS dependencies may need explicit configuration in older setups. And dual publishing doesn't remove the need to test the CJS artifact in a real Node process: node -e "require('./dist/index.cjs.js')" is a cheap smoke test that catches interop problems the ES build won't reveal.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.