Decreasing bundle size by adding Package Conditional Entry Points in NPM Packages

package entrypoints


Among the evolution or large codebases, concerns about performance are always raised. That might be by different techniques: dynamic async imports, defining Critical Resource, Critical Path Length and Critical Bytes, lazy loading images via libraries or by using the browser-level image lazy-loading support and so on and so forth.

Even though these are quite good ideas, having application performance boost as final results, it's usually done on the application side. If you're using design system and javascript libraries you should also care about their bundle sizes.

Some of these points were covered on "Solving a problem is more than just adding a new dependency" post. The intention of this post is specifically to show you how to integrate entry points in your NPM Packages.

The problem with large Javascript bundle size

Even with the advent of HTTP2, prefetch, preload and parallelization, content minified, uglified and compressed, the bundle issue still persists. The bright side of it is that's a common solution and there are several ways to solve that.

For some applications this is more than enough, covering several scenarios that might affect the performance of your app. However, these approaches are focused on the file after-bundle procress, not covering usage before-bundle.

For scenarios like libraries, frameworks or design systems, having several components being shared across multiples pages and components of your project is a huge problem. Even having tree-shaking suport for your bundler, the algorithm is not covering all the cases.

For example, if you have a design system on the namespace @myapp/ and you have a button which imports some icons, you'll ending up having @myapp/buttons and @myapp/icons in your dependencies. Also, if your build distribution is sharing only CommonJS, you might have all the buttons and icons. And it's even worse, because CommonJS not tree-shakable! 😱

To ilustrate this scenario, let's imagine you have 2 components in your @myapp/buttons package. From now on you'll have Button and ButtonWithIcon and there are several components inside @myapp/icons - it can be more than 30 components, for example.

    buttons/index.js <-- final button with all components, even if you're using only 1 of them
    icons/index.js <-- final bundle with all icons, even the ones you're not using

Even knowing I'm using a single button and a single icon, how can I solve the problem of have all these unused components on the final bundle of my project?

Distributing ESM and CommonJS in your bundles to have tree-shaking support

First of all, you should make sure that all your components are having at least CommonJS and ESM in your bundle distribution. So that you can expose the different bundles in your package.json. ESM will be exposed on modules and commonjs will be exposed on main fields, respectively.

In a 100% ESM module world, identifying side effects is an easy job. However, we aren't there just yet, so in the mean time it's necessary to provide hints to webpack's compiler on the "pureness" of your code.

If you want to know more about side-effects, why they're required and the impact in your app, please read the Webpack's tree-shaking docs page

With all these changes in place, the first version of your package.json will be like that.

  "sideEffects": false,
  "name": "@myapp/buttons",
  "main": "../dist/cjs/index.js",
  "module": "../dist/esm/index.js",
  "cjs": "../dist/cjs/index.js",

Now it's time to have all the benefits of tree-shaking and reduce the bundle size of your app.

Code-Splitting and taking advantage of tree-shaking by reduce bundle size

Your @myapp/buttons package will have the build distribution organized that way.

    cjs/index.js <-- final button with all components, even if you're using only 1 of them
      ButtonWithIcon.js   <-- `ButtonWithIcon` component
      Button.js           <-- `Button` component
      index.js            <-- main file exporting both components

Your bundler will try to use ESM before CommonJS in order to avoid bundle increase. So it knows if your package has any ESM bundle by checking module field in your package.json.

You can notice that if you import the package using import Button from '@myapp/buttons/esm/Button.js that will work.

It is always a good to keep in mind that every choice has pros, but also have cons
It is always a good to keep in mind that every choice has pros, but also have cons

However, this is not ideal since you're coupling your app with the internal code structure of your dependency. This will raise massive issues with common tasks, such as update a package dependecy.

Now that your application knows too much about the dependency directories, that might also implies breaking changes in case of dependency structure changes as well. In summary, it means that if for some reason the build distribution was changed and now it's in @myapp/buttons/dist/esm/Button.js you have a broken app!

You should not access your dependency directories directly, unless it's something extremelly necessary. A small change in your dependencies and your app might be broken

    # Oops! Your app will face the consequences

Package Conditional Exports and Creating Declarative Entry-Points in your package.json

Let's make a quick recap before jump in this topic. Now we know that ESM is a tree-shakable distribution format and we can have the benefits by using it. We also know that we shouldn't access the package files directly by navigating through the dependency structure.

Some of the main concerns when using Package Entry-Points
Some of the main concerns when using Package Entry-Points

Keeping these points in mind, one way to solve is by using an entrypoint structure. Entry-points is a way to expose a specific function, class or component intentionally to be consumed to your users. So that, the consumers can only import a specific piece of code instead of all the functions of the used dependency, enabling bundle decrease and having a better understanding of what's being used in your app.

This architecture is called "Package Conditional Exports" - AKA "Package Entry-Points" - and some packages such as lodash and others are using this technique to decouple their functions, so their consumers can export only what's needed for them.

The idea here is to install all the package functions, but delay the decision of which function is required on development time. On lodash case, it add some benefits. So it won't be needed to install lodash.compact, lodash.debounce and other packages one by one since you can access them via entry-points via lodash/compact and lodash/debounce, respectively.

Entry points: Lodash example
Entry points: Lodash example

On the case of our @myapp/buttons, we can achieve the same idea by exposing entry-points in folders on the root project. Each folder will have a package.json with the specified configuration for it. This is great for your app and avoids breaking changes in case of code structure.

Even though this solves the problem, have a robust way to expose a set of functions if needed is something that's always useful. To have that in place easily, we can create a folder called entrypoints. The folder will have the entrypoints defined by files and, more than this, by features.

If you need a specific entry point to expose more than 1 component, that can be done easily by the file. In this case, 2 new components were added in @myapp/buttons: ButtonGroup and ButtonTheme.

Instead of creating a entry-point for each of the files, a new entry-point calledn @myapp/buttons/utils was created, giving the choice of export each of them to the app consumer.

// `./entrypoints/utils.js` file content
// Accessed via `@myapp/butons/utils` entry-point
export * from '../ButtonGroup';
export * from '../ButtonTheme';

Based on that specific case, the package json inside of @myapp/buttons/utils folder will be

"name": "@myapp/buttons/utils",
"main": "../dist/cjs/entrypoints/utils.js",
"module": "../dist/esm/entrypoints/utils.js",
"cjs": "../dist/cjs/entrypoints/utils.js",
view raw package.json hosted with ❤ by GitHub

With these changes applied, that will be the structure of your package, supporting entry-points 🤘🤘🤘🤘

      utils.js <-- Exporting `ButtonGroup` and `ButtonTheme` components
      ButtonWithIcon.js <-- Exporting `ButtonWithIcon` component
      Button.js         <-- Exporting `Button` component

Better Entry-Points support on NPM in a near future

As you might have noticed, entry-points are a massive help to decrease the bundle of your application, giving your more flexibility in your app, being easy to integrate - and also to remove, having a massive role in other performance initiatives, such as code-splitting, if necessary.

A lot of heavy-used packages are using it such as date-fns, Lodash and Design Systems such as Atlaskit are also using it - 🤩 I'm really glad to add the first support for entry-points in this Design System, in specific 🤩 - , having huge performance gains before-bundle and before deployment to production.

This technique is getting so much traction that a even better way to integrate them into your app is being defined by NPM. And - spoiler alert - the support will be built-in, in your package.json.

You can find more details about Package Conditional Exports on "Modules: Packages" NPM Documentation page

I encourage you to see all of these steps in action by using [perf-marks entry-points]( in your application. It's a cross-platform performance measurement package powered by User Timing API and it has a simple and straightforward usage.

You can find more details about Perf-Marks on "Cross-platform performance measurements with User Timing API and perf-marks" post.

That’s all for now

I hope you enjoyed this reading as much as I enjoyed writing it. Thank you so much for reading until the end and see you soon!

Cya 👋


To keep up with posts on this blog, you can also subscribe via RSS.