Why Rollup Ships Smaller Bundles: Tree-Shaking and Code-Splitting in Practice
Rollup's ES-module-native design enables aggressive tree-shaking and automatic code-splitting. A reproducible example shows both mechanisms, plus the sideEffects and CommonJS pitfalls.
16 Sept 2026, 17:16 UTC

Your app imports one debounce function from a utility library, yet users download the whole library. Rollup exists to fix exactly that, and it does so with two mechanisms worth understanding properly: tree-shaking (dropping code you never use) and code-splitting (loading code only when it's needed). This post walks through how each works, with a build you can reproduce and inspect yourself.
Tree-shaking works because ES modules are static
Rollup was designed around ES modules. Because import and export statements are static — they can't be computed at runtime — Rollup can trace exactly which exports your entry point actually uses and drop everything else before emitting a single byte of output. This isn't an optimization pass bolted on afterward; it's the core of how Rollup builds its module graph.
Tree-shaking is deliberately conservative. If Rollup can't prove a statement is free of side effects, it keeps it. A module that registers something globally or patches a prototype at the top level will survive the build even if none of its exports are used. That's the safe default, but it means library code often shakes worse than it should.
The sideEffects hint unlocks aggressive elimination
Package authors can declare "sideEffects": false in package.json to tell bundlers that importing a module has no observable side effects — it's safe to drop entirely if unused. For library code this is the single biggest tree-shaking lever. You can also control it per-build with Rollup's treeshake.moduleSideEffects option.
The warning cuts both ways: if a module does perform initialization on import (a polyfill, a global stylesheet injection, event listener registration) and it's marked side-effect-free, Rollup will silently remove it. Treat the flag as a contract, not a size hack.
Code-splitting: automatic via import(), manual via manualChunks
Rollup splits output automatically in two situations: multiple entry points, and dynamic import() calls. When two chunks share a dependency, Rollup emits a shared chunk rather than inlining the code twice — so a lazily loaded route doesn't duplicate utilities already in your main bundle.
When you want explicit control, output.manualChunks lets you define chunk boundaries. The classic pattern is splitting large third-party dependencies into a vendor chunk that changes rarely and can be cached for a long time, while your application code (which changes every deploy) stays small and cheap to re-download.
A worked example you can inspect
Set up a minimal project (Node.js, Rollup installed as a dev dependency — these commands run in your project directory):
npm init -y
npm install --save-dev rollupCreate src/utils.js with several exports, only one of which we'll use:
export function debounce(fn, ms) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
export function heavyUnusedHelper() {
// imagine 200 lines of code here
return 'unused';
}Create src/route.js as a lazily loaded module, and src/main.js as the entry:
// src/route.js
export function render() {
return 'route content';
}
// src/main.js
import { debounce } from './utils.js';
const load = debounce(() => {
import('./route.js').then(m => console.log(m.render()));
}, 300);
load();And a rollup.config.mjs:
export default {
input: 'src/main.js',
output: {
dir: 'dist',
format: 'es'
}
};Build with npx rollup -c and inspect dist/. You should see a main chunk and a separate chunk for route.js, loaded on demand. Open the main chunk: debounce is there, heavyUnusedHelper is gone. That's both mechanisms visible in one build — verify by checking file sizes and searching the output for the unused function name.
To see the vendor pattern, add a real dependency and extend the config:
output: {
dir: 'dist',
format: 'es',
manualChunks(id) {
if (id.includes('node_modules')) return 'vendor';
}
}Trade-offs and limitations
- CommonJS shakes poorly. Consuming npm packages requires plugins (
@rollup/plugin-node-resolve, and@rollup/plugin-commonjsfor CJS). CommonJS lacks static export structure, so converted modules often survive tree-shaking wholesale. Prefer ESM builds of libraries when available. - Keep manualChunks simple. Chunk functions that create circular dependencies between chunks can produce broken or surprising output. A single node_modules rule is usually enough.
- Side effects are your responsibility. Misapplied
"sideEffects": falsedrops initialization code without any build error — the failure shows up at runtime. - Option names drift across major versions. Confirm
manualChunks,moduleSideEffects, and plugin package names against the Rollup version you actually have installed (checknpx rollup --versionand that version's docs or changelog).
Where to go from here
Run the example above, then toggle "sideEffects" in a test package and compare output sizes — seeing the bytes disappear teaches more than any config reference. Once the mechanics click, audit your real dependencies: check which ship ESM builds, which declare side effects correctly, and whether a vendor chunk would improve your caching story. Rollup gives you the levers; the inspection habit is what makes them pay off.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.