Steve Workman's Blog

Bundle analysis deep-dive - how to remove a megabyte of code from your app

Posted on by Steve Workman About 7 min reading time

Summary

In this post, I delve deep into:

It all started with a tweet

Part of my role at the office involves helping teams to code in a standardised way. We enforce this through a wide variety of tools that correct as you write (ESlint) and those that check your work (unit tests, Sonar).

To make life even easier we have a design system to standardise our HTML and CSS, and a common library of components that implement the basics of the design system in VueJS web components. This makes it hard to get the basics wrong.

Whilst making a large update to how this library is structured and compiled, I moved the library build process to Rollup. We had previously been publishing untranspiled JavaScript to apps that then had to transpile them themselves to meet our target browser support. This keeps bundle size minimal but can cause interoperability and portability problems, for example, configuring Jest is much harder.

On release day, I did a demo in front of the whole company's VueJS dev community - and the Vue build process reports that my bundle is 2MB for first load - up from 270KB!

alt-text

💩

So, feeling bad that this has happened, and knowing that developers will start running into this on Monday morning, I start fixing it on Saturday night - I mean, entertainment options are limited right now.

My first stop is the webpack bundle analyser, which gives a great overview of where the big bits of my bundle are. This is built in to the Vue CLI with the --report option, so that's nice and easy to get.

Then I see this:

alt-text

Since I wrote these libraries here, I know what they are and what they contain. I can confidently say that vue-modal contains vue-core and both contain shared-js and all of the core-js polyfills that they need.

I also built all of these libraries with rollup, so I can go and check what is going on. Rollup's documentation says that Rollup does tree-shaking with ES modules, which these all are. It also says that tree-shaking is hard and "that's just JavaScript". Yeah... thanks.

alt-text

The sceptical part of me doesn't believe that, so starting with the biggest chunk of code, the modal. One of the suggestions in the Rollup docs is to use a lodash-es style library and only reference the files I need - effectively doing manual tree-shaking. So, I reference the file that I need, which I can do in this new app structure - very handy. It reduces the bundle size from 200KB to 45KB.

75% code reduction - time to celebrate? Nope - 45KB is still huge for this component, so the next stop is the rollup bundle analyser, which is a plugin to the rollup config.

alt-text

It says most of the code is core-js polyfills. That would be important if I wanted to let people load this directly in a browser from a CDN, but I don't. I'm only interested in using this as a library with other VueJS apps and webpack.

With these dependencies under my control in rollup, I turn off the node-resolve plugin and magically, 45KB is now 7KB!

However, there's a snag - I now have a dependency that won't exist when I publish this as I don't want to include the source code for the SFCs when it is published. Therefore, as it's not tree-shaking well, I do some scripting with the rollup API to make a file for each component of vue-core that I can reference from vue-modal. These will be distributed as transpiled libraries along with the full library file.

Hindsight note here - this isn't always going to be necessary, once the dependencies are sorted out - the right things resolved and the right ones marked as external, this all becomes moot and in some situations can increase bundle size - as I will find out later.

alt-text

With these changes, the bundle size for the modal component is now 3KB! 🎉. All the components generated are between 3 and 9KB in size and simple to publish - or at least I thought so.

alt-text

I tried my code out in a test app, the same one that had the huge build earlier. I tried to link the two together with npm file paths, only to get build errors. It appears that the dependencies of the library aren't available because they're in a Lerna monorepo and my app can't resolve the hoisted modules - it's just not working.

Thankfully, like all good Super Villains, I have a plan B.

alt-text

I need somewhere to publish my unfinished node modules. I don't use github for this code, don't have private npm and the office one doesn't have a nice sandbox to use. So, I have a local docker solution called Verdaccio. I spin this up and can publish my unfinished modules there for this very occasion.

Pulling the code from there makes my build errors go away (after changing the reference in my modal component to use the new rolled up single file).

alt-text

This build above is a big improvement - vue-core also has its dependencies externalised and the result is great, 2.09MB stat down to 1.18MB - 910KB off every run of the app. Yay!

At this point, hindsight kicks in. Whilst looking for a solution to referencing this SFC from the dist folder with lerna (note - no good answers) I go back to the import {} from ... syntax and it saves another KB. Tree-shaking is now working again.

So, Saturday night has been saved, I have promising answers for the team on Monday, but there's still more to do. I've got the Vue component libraries under control, now to take on the shared-js library as that is far too big as well.

Shared JS before externalising
Shared JS after externalising

The same trick works here as well, turning off node-resolve gives me a 5x size reduction, removing 278 modules in the process

Whilst I'm looking at this, my mind wandered and I looked at the cost of all of these polyfills - basically the cost of supporting older browsers whilst writing new code. These 278 modules are all core-js, the polyfill library, and they're only the files that are in use in my library here.

The default browserslistrc file, which allows me to control the polyfills that are added based on browsers we want to support and the features they're missing, has a setting of "last 2 versions and > 1% usage and IE 11 Only".

Browsers with > 5% support bundle size
Only Chrome bundle size

Taking out just IE11 from support does nothing, no bundle size change. It's not just IE that causes these issues. Going with just browsers with > 5% support (Chrome and iOS Safari) it still needs 100KB of polyfills. Chrome-only has a 4KB overhead.

The cost of supporting other browsers, and ones that aren't that old either, is high.

alt-text

Back to the topic, a bit more re-publishing and we are unsurprisingly 200KB lighter, with the vendors chunk weighting in at 977KB stat, 97KB gzipped (and about 87KB brotli'd). This is all really good until I look at the pre-upgrade version and see that it's still 206KB lighter.

alt-text

I'm not going to make all of that difference up. core-js 3 has fixes, more polyfills and more patches and there are other upgrades too that could make this bigger. But still, 20% of the bundle size is significant.

alt-text

I've clearly forgotten my lesson of hindsight from earlier as I try to use the pre-built SFCs instead of the destructured library to get it down. That adds another 37KB stat instead of taking it away. Going in to the source code that rollup generates shows a lot of VueJS boilerplate, confirmed by gzip compression being a mere 1.1KB larger. Still, bigger is bigger and I take it out.

I come to the conclusion that my own libraries may not be the problem any more, which is good, so let's look at another bundle. It seems that core-js has some duplicates over in @vue/cli-plugin-babel.

alt-text

I'm gonna skip a bit of my analysis here as I did a bunch of wrong things with the babel config that were not necessary. My issue here was pretty simple - npm hadn't de-duped a module even though the package was identical.

alt-text
Note Jest 25 & 26 are still core-js 2 - the screenshot is wrong

Running the npm dedupe command fixes this and the bundle size drops 30KB stat - 4KB gzip.

Now that is de-duped, how much further can we go? Well, without ripping code/features out, not a lot. The core-js bundle is now 69KB bigger than before. A third of this is the new polyfills like web.url, and the rest is supporting implementation.

So, was the old app broken without this code?

Genuinely, it might have been. It may also be because the previous build used Babel's useBuiltIns: "usage" option that has gotten a lot more accurate with core-js 3. Both of these things are announced in the core-js 3 blog post. It may also be other updates to use URL that weren't in earlier versions of our libraries. It may have been this strategy that this polyfill strategy has introduced compatibility bugs. I'm sad, though I am also more understanding of teams that resorted to throwing the whole @babel/polyfill library at the problem. I really don't know which of these the answer is, though our logs don't show anything bad happening here in production.

So, I stop arguing with core-js, and spend a few minutes on the next biggest chunk - Luxon.

Luxon is a smaller date library (than Moment) with the timezone features a global logistics company needs (I can't wait to be able to use a native API for this). It is not transpiled (as the website makes clear) and so this should be simple. However, Webpack is loading a version with all of the dependencies bundled.

alt-text

Webpack loads modules in a priority order of browser -> module -> main (these are named properties in the package.json file of each module) when building a browser-based target. However, for Luxon, it's more appropriate to select the ES module because of the way we're building and how Luxon is built.

A short change to the webpack config removes 24KB stat of code and overall it's down 4KB gzipped. I save another 5KB by not re-transpiling our own libraries - a hangover from the old version of the code that isn't necessary now. I run it in IE, get mad that I forgot to change the browserslist file back, and we are now at 92KB gzipped for the vendors chunk, 14KB up on last time.

alt-text

So, I've gone from megabytes over to 14K with a lot of fiddling with external dependencies, library build processes, and over-optimisation.

In summary, always check your bundle sizes, manage your dependencies well, and know your audience; give them what they need, and no more.

If you want to read more about JS framework sizes from the other end of this telescope, I highly recommend Tim Kadlec's article, The Cost of JavaScript frameworks. Finally, I was super happy that Ethan Marcotte mentioned my tweet thread in his blog Gardened - thanks Ethan!