It's easy to add a bunch of npm packages to a project. It's also just as easy to add so many that it takes ages for your bundle to to build, download and execute. In the real world this translates to bad user experience or worse: losing users entirely.
I had some spare time this weekend and did some refactoring of my personal site, getting rid of the packages I didn't need and got the project's bundle from this:
public/index.853702c4.js 282.07 KB 1.49s
├── /react-dom/cjs/react-dom.production.min.js 257.67 KB 48ms
├── /popmotion/dist/popmotion.es.js 62.27 KB 16ms
├── /popmotion-pose/dist/popmotion-pose.es.js 33.59 KB 66ms
├── /stylefire/dist/stylefire.es.js 25 KB 7ms
├── /pose-core/dist/pose-core.es.js 21.74 KB 7ms
├── /react-pose/dist/react-pose.es.js 21.67 KB 85ms
├── /@emotion/stylis/dist/stylis.browser.esm.js 19.88 KB 4ms
├── /@popmotion/popcorn/dist/popcorn.es.js 17.37 KB 7ms
├── src/js/legos.js 16.08 KB 318ms
└── /react-inlinesvg/esm/index.js 14.52 KB 207ms
└── + 79 more assets
To this: ✨
public/index.1d2e670f.js 53.59 KB 348ms
├── /preact/dist/preact.module.js 31.56 KB 19ms
├── /@ctrl/tinycolor/dist/module/index.js 19.45 KB 5ms
├── /preact/compat/dist/compat.module.js 17.13 KB 18ms
├── /react-meta-tags/lib/meta_tags.js 9.39 KB 64ms
├── /@ctrl/tinycolor/dist/module/format-input.js 7.68 KB 8ms
├── src/js/app.js 7.52 KB 139ms
├── /preact/hooks/dist/hooks.module.js 7.25 KB 21ms
├── /@ctrl/tinycolor/dist/module/conversion.js 6.44 KB 76ms
├── /react-meta-tags/lib/utils.js 5.88 KB 4ms
└── /react-meta-tags/lib/meta_tags_context.js 5.07 KB 3ms
└── + 25 more assets
1. Use smaller libraries ✂️
This one only applies to React-based projects, but the simplest way to cut out a sizeable chunk from your bundle is to swap React for Preact. There are guides for doing this process in a few steps, and with the preact-compat
compatibility layer chances are you won't notice a difference (except for the significantly smaller bundle size!)
Beyond this, take a hard look at your dependencies and decide if you really need all the features they provide. Even small packages can stack up over time. Tools like bundlephobia are helpful for finding smaller alternatives to a library with a similar API.
But even then, you may still be left with a bunch of packages that you don't necessarily need.
2. Rewrite library-heavy code 🗑
Bye emotion 👩🎤
After using bundlephobia to replace some libraries and make small changes so things still work I realized there wasn't a good reason why I needed some of them at all. Obviously this is only relevant on a case-by-case basis, but the smallest library to affect your bundle size is no library at all!
For example: I was using emotion to style components, but this was overkill for such a small project. There was no good reason why I needed to keep it, so I just scrapped it for old-fashioned CSS and let the bundler take care of it.
Some logic that relied on props
to define a styled component's coloring needed to be rewritten but that was easy with CSS variables. This:
const Brick = styled.div`
.child-class {
background: ${props => darken(0.08, props.color)};
}
`;
<Brick color="#fff">
{children}
</Brick>
Which used both @emotion/styled and polished, was rewritten to use a much smaller color utility library:
const color = new TinyColor(props.color).darken(80).toString();
const cssVars = {
'--color-1': color
};
<div style={cssVars} className="brick">
{children}
</div>
Combined with some CSS:
.brick .child-class {
background: var(--color-1);
}
And the resulting behavior is identical! Removing emotion shrank the bundle significantly. The next biggest one would be getting rid of the library that was added to handle animations.
Animation library go poof 💨
Framer Motion (previously react-pose) is a powerful animation library. But in my case, too powerful. I added it to play around with moving elements around but it was blowing up the project's bundle for just some simple entry animations.
I ended up replacing the motion
component with a class to apply a CSS transform
then a useEffect
to remove the class after a delay. The new behavior very closely resembles what was before, and it's definitely close enough to rationalize removing such a massive dependency (almost 100kb alone!).
3. Always tree-shake 🌳
Tree shaking is not a new concept and all modern bundlers support it. The simplest example is instead of importing an entire massive library like lodash:
import lodash from 'lodash';
const number = lodash.random(0, 10);
Use a tree-shakeable library that lets you only import what you want:
import random from 'lodash-es/random';
const number = random(0, 10);
That way your bundler can ignore the unused portions of a library and only include what's needed. Not every library supports this however; it's wise to seek out the ones that do.
Analyze bundles frequently 🔍
It's always good to keep track of these things over time so performance doesn't slide. Parcel, which I used for this project, has a helpful bundle analyzer (similar to the one for Webpack) that gives a nice visual overview of a project's bundle. This is especially helpful for identifying bundled dead code coming from packages that could be avoided with tree-shaking. There are also plenty of tools you can integrate with CI to enforce bundle size.
End result ⚡️
This project now takes less than a second to build and the gzipped bundle size is down from ~150kb to only 18kb! The page loads significantly faster and the dev experience is much smoother too.
Hopefully these basic concepts are helpful, please share any tips I didn't cover!
Top comments (2)
I see you are using CSS variables for custom color. Would you like to try Vanilla Extract? It is like TypeScript version of CSS Module and it also uses CSS variable.
And of-course code splitting should be used where ever possible. Using const for objects and cleanup function to unmount any event listener used.