I recently had a chance to work on a component library distributed through NPM and consumed by several applications. The library is bundled with tsup and it seemed to work great, until I took a look at our bundle sizes and saw that we have a problem.
While the library was properly bundled, and build times were very quick thanks to ESBuild, I noticed that our application bundles were too big to seem reasonable.
When importing the
<Button /> component, Import Cost reported 97Kb added to our bundle. Don't get me wrong, we have nice buttons, but not 97Kb worth of buttons. Importing
*.svg icons resulted in a similar bundle size added to our application's bundle.
I tested it in multiple environments. Both
webpack (in a
Next.js application) and
vite behaved similarly, so I figured out our library was not as tree-shakable as I thought it was.
As it turns out, creating a tree-shakable library can be tricky. In this post I'll walk you through how to use
tsup to create a tree-shakable library.
What is tree shaking?
First, an introduction to tree-shaking. Taken from the webpack documentation:
To illustrate how it works, let's say we are working on a component library. We currently have 2 components - a
Button and a
Button component is very simple:
But for the
Dialog component we'll use the excellent
@radix-ui/react-dialog. I pulled the example from their docs.
Without any other external dependencies, our button component weighs about 99 bytes, and our dialog weighs about 50kB (minified, not gzipped).
Different applications may import different components from the library. Some only need the button, others the dialog and some need both. The real library naturally has many more components. We want each application to produce a bundle that only contains the code that it imports.
Without tree-shaking, each application that uses the
<Button> component will have 51.9kB it doesn't need added to its bundle size. As our library grows larger, this will become an issue. It is not unheard of to have component libraries that contain 100kB+, where each application only needs a sliver of.
We definitely don't need 100kB extra in our landing page.
To address this issue, bundlers such as webpack and rollup perform an operation called tree-shaking. In the tree-shaking process the bundlers will scan our code to figure out what external pieces of code we imported, and only include that code in the final build.
Requirements for tree-shaking
To get tree-shaking working reliably, distribute your code in ESM format. ESM uses statically analyzable
import/export statements which bundlers can use when bundling your code. Webpack and rollup support it as well as modern browsers, so it's a safe compilation target.
When bundling with
tsup, the default (and somewhat outdated) format is
cjs. Change that to
esm either via the command line:
Or via the config file:
If you are bundling your library with typescript, set the
compilerOptions.module to anything above
No side effects
The second thing you should do is make sure that your code has no side effects. From the webpack docs:
A "side effect" is defined as code that performs a special behavior when imported, other than exposing one or more exports. An example of this are polyfills, which affect the global scope and usually do not provide an export.
Basically, any code that performs an action, alters the global state or does anything else when imported is considered to have side effects.
This is critical because while side effects are rare, if you do have code that is not pure, like polyfills or otherwise, you might not want it excluded from your bundle when the imported module is not referenced. This can include CSS files that were imported but not referenced.
In webpack the config is called
sideEffects and in rollup it's called
moduleSideEffects. Both can receive the value
false to indicate that there are no side effects in the code, or a list of files that do contain side effects.
Separate files as entry points
Tree-shaking works best when bundlers can exclude entires files from the bundle. While modern versions of webpack and rollup can remove unused parts withing the same file, it's best to stick to the lowest common denominator to produce the most predictable results.
What this means for you in practice is that you should either configure your library to have multiple entry points, or have a single entry point that imports and exports your code from separate files. In my experience, both methods yield similar results, so it's up to you to decide which one you prefer.
dist/ folder of a library with multiple entry points would look like this:
And when importing code we would import from the relevant file:
While this works, it's not very ergonomic when you import multiple things from your component library in the same file:
To solve that you can bundle your library so that you still have a single entry point, but it points to separate files that can be excluded individually. It should look similar to this, although it may be messier due to the way bundlers split files when they bundle.
index.mjs would look like this:
And now your imports look like this:
Bundling a library with
Now that we know what we need to do to get tree-shaking to work, let's see how we can do that with
tsup is a zero-config bundler that uses
esbuild under the hood. To get it working properly we would need to configure it a bit.
Next, create a
tsup.config.ts file at the root of your project, and add the following code:
tsup docs will tell you to specify an entry point such as
tsup will crawl all files imported from that entry point and bundle them into a single
index.mjs file. While this can work, as explained earlier, we prefer to bundle each file separately.
tsup to bundle each file separately, we need to change the
entry option. This option accepts a glob pattern, so we can specify all
.ts files in the
Don't forget to include
format: ["esm"]. This will tell
tsup to output ESM files, which is the only format that supports tree-shaking. You can choose to output
cjs as well if you want to support older bundlers, but it's not necessary.
If you are working with other file types such as
.jsx you can add them to the glob pattern, like so:
You can also have
.d.ts files as well.
The last part we need is to let the bundlers that will consume our library know where to import files from. We also need to let them know we don't plan on performing any side effects that may make them bail out of tree-shaking.
package.json and add the following lines:
exports field specifies where to import files from. The
exports["."] identifier means that imports from the root of the package (
import * from "package-name") will be imported from the file in the
If we wanted to allow importing from a sub-module, we would specify it as well. For example, to allow importing a CSS file named
style.css, we would add the following line to the
exports["."].import field is for ESM bundlers, and the
exports["."].require field is for CJS bundlers. The
exports["."].import.default field is for the default import, and the
exports["."].import.types field is for telling typescript where to find the types for our files.
Other than that we also specify our
main file, which is used by older bundlers that are not aware of the
exports syntax, as well as
types which is used by typescript, and last we specify that our code has no side effects.
This should be everything you need to bundle a tree-shakable library with
Comparing the numbers
Let's make sure our library is tree-shakable by looking at some numbers. I've created a repository with an example package, and a couple of brand new applications -
Vite with a react template.
The library contains 2 components - a
Button and a
Dialog component. The
Button component is a simple component that renders a button, and the
Dialog component is a wrapper around
Both applications contain the same code in the index route. Note that in the
Next.js app I've poisoned the root page with
"use client" to make sure the page is not a React Server Component.
Let's first look at the application bundle sizes when importing both components.
I've highlighted the relevant line - our initial bundle is 92.6kB. Let's see the
Vite application results:
Vite bundle is 59.17kB.
So that's our baseline - 92.6kB for
Next.js and 59.17kB for
Now let's see what happens when we don't import our large
We got 81.9kB and 48.9kB respectively. That's about a 10kB reduction in bundle size, which is very respectable. This is the true power of tree-shaking - only bundle what you need.
The final config
Finding the right config has been a bit of trial and error for me, so if you have any suggestions or improvements, please let me know.
Tips and Tricks
Lastly, I've wanted to mention a few tools that helped me identify tree-shaking issues.
The first is Import Cost. It's a VSCode extension that show the size of imported modules right in your editor. Very useful for quick checks.
The second is bundlephobia. If you publish your library to
npm, you can use bundlephobia to check the size of your library. You can also check other libraries if you are concerned about bundle size.
The third is publint. It's a very useful CLI that validates that your packages are configured properly to be published. It's not specific to tree-shaking, but it's an excellent tool to have in your toolbox.
Thanks you for reading!