Creating a tree-shakable library with tsup
September 18, 2023I 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:
Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export. The name and concept have been popularized by the ES2015 module bundler rollup.
To illustrate how it works, let's say we are working on a component library. We currently have 2 components - a Button
and a Dialog
component.
Our 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
ESM
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 ES2015
, preferably ESNext
.
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.
The dist/
folder of a library with multiple entry points would look like this:
dist/
├── button.mjs
└── dialog.mjs
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.
dist/
├── index.mjs
├── button.mjs
└── tooltip.mjs
And your index.mjs
would look like this:
And now your imports look like this:
Bundling a library with tsup
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
. 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.
First, install tsup
:
Next, create a tsup.config.ts
file at the root of your project, and add the following code:
The tsup
docs will tell you to specify an entry point such as src/index.js
, and 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.
To get 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 src/
folder:
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 .tsx
, .js
or .jsx
you can add them to the glob pattern, like so:
You can also have tsup
generate .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.
Update your package.json
and add the following lines:
The 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 default
field.
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
object:
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 tsup
.
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 - Next.js
and 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 @radix-ui/react-dialog
.
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:
The Vite
bundle is 59.17kB.
So that's our baseline - 92.6kB for Next.js
and 59.17kB for Vite
.
Now let's see what happens when we don't import our large Dialog
component.
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
The tsup.config.ts
:
And the package.json
:
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!