articles
The Hidden Cost Of Tree-Shakeable Libraries: Part I
6 min read
TLDR: As I was planning on writing this article, I realised that I had made some crucial mistakes in how I package my libraries since I misunderstood how bundlers perform tree-shaking and minification. As of today ( November 22nd 2025 ), all my libraries are tree-shakeable, not tree-shaken or minified giving end users full control over their final bundle size.
To fully grasp what we’re about to discuss, we need to revisit a few definitions and start with what tree-shaking actually is. Tree-shaking is an optimisation technique used in the JavaScript ecosystem to eliminate unused modules and named exports, resulting in a smaller and more efficient final bundle. It works by performing static analysis on ES6 module imports and exports.
Modern bundlers such as Webpack, Rollup, Esbuild, Turbopack, Parcel, Rspack, Rolldown, Farm and Bunup support tree-shaking out of the box. However, the effectiveness of tree-shaking depends heavily on how your code is structured and how your bundler is configured.
The static nature of ES modules makes them ideal for tree-shaking, which is one reason why the JavaScript ecosystem has been shifting toward ESM only builds. In contrast, CommonJS modules are dynamic, making static analysis significantly harder. While tools like Vite do support CJS, they must first transform it into ESM during the build process, which adds overhead and limits the effectiveness of tree-shaking.
It might be tempting to think that simply using ES6 module syntax and configuring your bundler correctly is enough to achieve an optimal bundle size but it’s not. One important concept that is often overlooked is side effects. A side effect is any code that runs automatically when a module is imported, even if nothing is actually used from that module. Examples of side effects include:
- Top-level logging.
- Top-level function calls.
- Top-level storage writes.
- Top-level addition or removal of event listeners.
- CSS imports.
- Modification of global variables.
By default, bundlers may assume your code has some side effect so as not to break or remove anything crucial and this behavior adds some few kilobytes to your bundle. To know if your package has side effects, simply ask yourself: “If this file is imported but never used, will anything happen?”. If the answer is no, then your package has no side effects and you should set your sideEffects field in the package.json file to false. If yes, take note of the files that have side effects and explicitly list them in your sideEffects array so as to prevent the bundler from assuming the entire package has side effects.
Tree-shaking removes unused modules and named exports at the module level whereas minification removes dead code, whitespaces and comments inside a file. In simple terms tree-shaking reduces the quantity of code while minification reduces the size of code.
Tree-shaking example by tsdown:
import { hello } from './util'
const x = 1
hello(x)export function unused() {
console.log("I'm unused.")
}
export function hello(x: number) {
console.log('Hello World')
console.log(x)
}
// With tree-shaking
function hello(x$1) {
console.log('Hello World')
console.log(x$1)
}
const x = 1
hello(x)// Without tree-shaking
function unused() {
console.log("I'm unused.")
}
function hello(x$1) {
console.log('Hello World')
console.log(x$1)
}
const x = 1
hello(x)In part II of this series, we are going to be more hands on in terms of experiments.
Trade-offs
In engineering, everything is a tradeoff. When it came to packaging my libraries, I traded flexibility for a smaller bundle size which was very costly to the end user. I made premature optimisations by applying tree-shaking and minification at the library level instead of letting the user’s bundler perform those optimisations during the build process. When a library is shipped pre-minified and pre-tree-shaken, the code looks small and clean, but bundlers lose their ability to perform static analysis. Minification collapses identifiers, removes structure, and flattens module boundaries which are exactly the things bundlers rely on to build an AST (Abstract Syntax Tree). Without a clear AST, the bundler cannot determine what exports are unused, and it cannot remove dead modules or inline small functions. The result? The entire bundle size that you were happy about being small is shipped in its entirety in your final bundle.
If you are wondering whether there is a difference in the bundle sizes, check the images below:
Pre-mature tree-shaking, minification and sideEffects ( set to true by bundlers ( default ))

Pre-mature tree-shaking, minification and sideEffects set to false ~ 9.48 KB smaller (0.44%)

No tree-shaking nor minification and sideEffects enabled ( set to true by bundlers ( default ))

No tree-shaking nor minification and sideEffects set to false ~ 8.52 KB smaller (0.36%)

With sideEffects set to false, we shed off a few kilobytes.
How did I end up making such a mistake you might ask? It came down to two bad assumptions:
1. I assumed the bundler’s default were correct and mandatory.
In both tsup ( no longer maintained ) and tsdown, the treeshake option is set to true by default so I rolled with it. The truth I didn’t understand at the time was that:
- Library authors should output tree-shakeable code.
- The user’s bundler should perform the actual tree-shaking.
2. I enabled minification because the word minify sounds great
By default, both tsup and tsdown set the minify option to false. In English minify means to make something small and who doesn’t want a small bundle size? So I flipped it to true.
The quote: “Just because you can doesn’t mean you should” applies perfectly here.
Bundlers can and will tree-shake libraries but that doesn’t mean you should do it for them.
For those interested in benchmarks, you can checkout the TS Bundler Benchmark site to compare the performance of popular TypeScript bundlers when bundling a thousand functions.