10
minute read
Ben Gourley

How to make your JS bundle really small in 4 easy steps

The web has a bloat issue. Data connections are getting faster and devices are getting more powerful, but does it ever feel to you like the web is getting slower?

The truth is that the web is ballooning quicker than technological advancement can keep up with. Compounding this further, powerful devices and good connections are hardly ubiquitous. Most devices people use to browse the web are budget smartphones and laptops, and the fastest data plans or broadband connections are not accessible for the masses.

As engineers we are the gatekeepers of what resources get delivered with the page, and so it's important for us to champion the idea that less is more.

Faster pages are a win for everyone — a better user experience, better conversion rates (if you're selling something), lower operating costs, and lower energy consumption.

What follows is a set of ideas to reduce the JS footprint of your page.

Do you really need it?

Before diving in and attempting to shave off the odd byte of a multi-megabyte payload, it's helpful to zoom out and look at everything going into your app and figure out if it's really necessary.

Are there two things included that essentially do the same job?

Some of the most jaw-dropping examples I've seen before are duplicate frameworks/libraries being included:

  • jQuery and Mootools
  • React and Angular
  • Lodash and underscore

Sometimes these scenarios are unavoidable, for instance if you're undertaking a major refactor. In the transition from one framework to another, you might take the hit on page weight to make the refactor more manageable, but this is only acceptable as a temporary measure.

In any other circumstance, you really only want one thing that does a specific job. At times, this requires a bit of discipline or self restraint. If you're tasked with implementing a new view on a project that uses Angular, but you can't stand that and really want to use React instead… you've just got to suck it up and use what's already there!

Does everything included warrant its inclusion?

A different, more subtle case is pulling in a large dependency but only using a subset of its functionality. For example, using moment.js to format a timestamp as “x seconds ago” or using React to render a very simple interface that has only very limited functionality.

Modern bundler techniques such as tree shaking can help with this kind of problem, but that requires the included libraries to be structured to support it. It's best to look at all of your dependencies with a degree of skepticism and ensure they are worth their inclusion.

Are there unnecessary features in your app?

That easter egg you added, that snazzy message that developers see when they pop open the console, that April Fools joke, those animated snowflakes you added around festive season… Without wishing to suck all of the joy from your app, consider whether all, or any of these are worth it?

The elephant in the room: do you need those third party SDKs?

Bugsnag's JS SDK is something we think is essential for the stability of your app, but we understand that including Bugsnag is a tradeoff — it adds weight to the page. Having Bugsnag means that for the cost of increased bundle size, you're able to understand and fix the errors that your users see. If performance is a sliding scale, stability is an on/off switch — it's no good having a lightning fast website, if all it does is crash!

But what about all those analytics, advertising, A/B testing SDKs that are included on the page? Often these integrations can be configured via a CMS or are added just in case they are used.

It's worth going through and checking out whether every SDK you include is genuinely used, and if it's worth its weight penalty.

Understand compression

It's generally well practiced to minify and gzip your JavaScript, so it won't be news to you that I'd recommend that, and it's mostly done automatically for us by various tools these days. But sometimes changes that result in a decrease in the input size to your bundle can actually result in an increase in the output size. This certainly surprised me! So when you're trying to eek out every last byte, it's worth understanding exactly what goes on.

gzip

gzip is a fast, widely supported compression algorithm. Speed is important for web compression, since all of the benefits of a tiny over-the-wire size are outweighed if it takes a huge amount of time to decompress.

The algorithm gzip uses is called DEFLATE, and it combines two strategies: Huffman coding and LZ77. Both of these strategies love repetition — their approach is super effective on data that contains multiple occurrences of the same chunk.

The following contrived example illustrates the difference between two 32 byte strings. One string of alternating four character sequences, and containing unique characters:

-- CODE language-javascript --
% echo 'ABCD1234ABCD1234ABCD1234ABCD1234' | gzip | wc -c
      32
% echo 'ABCDEFGHIJKLMNOPQRSTUVWXYZ012345' | gzip | wc -c
      53

Note that the output is actually bigger than the input for the unique example — this is because the compression algorithm has to store information about the transformations that get made, so it can be decompressed. Don't worry, this is only drastic because the strings are trivially small to begin with. This overhead is much less significant when you're processing files in the order of kilo and megabytes.

The takeaway here is that repetition is actually a good thing for compression, and the longer the common substring, the better the savings that can be made. Counterintuitively, this means that "golfing" your code — the practice of throwing out the rulebook in terms of legibility (and other sensibilities) to make it as small as possible — can have the opposite effect.

In a practical example, you might spot a couple of functions that contain mostly the same logic, save for a few differences. It's reasonable to assume that factoring out the common parts of the logic into one function and calling it in different ways would be both better in terms of coding style (DRY principle) and save some bytes in the bundle. But it's likely this would be a case of premature optimisation, and while the bundle input might be smaller, the compression won't be as effective and it may result in a larger compressed output.

Minification

The process of minification does some basic transformations such as stripping whitespace, comments and punctuation. This means you can lay out and annotate your code however you like with worrying about changing how effective the minification process will be.

But some of the more complex optimisations that are made can vary based on the structure of your code. Compare the following programs with their minified counterparts — one defines some functions in the current scope, the other hangs them off a namespace:

-- CODE language-javascript --
var Model = {
  counter: 1,
  increment: function () { this.counter += 1 },
  decrement: function () { this.counter -= 1 }
}
Model.increment()
Model.decrement()
console.log(Model.counter)

// minifies to...
var n={counter:1,increment:function(){this.counter+=1},decrement:function(){this.counter-=1}};n.increment();n.decrement();console.log(n.counter)


var counter = 1
var increment = function () { counter += 1 }
var decrement = function () { counter -= 1 }

increment()
decrement()
console.log(counter)

// minifies to...
var n=1;var o=function(){n+=1};var a=function(){n-=1};o();a();console.log(n)

You can see that in both versions some variable names are shortened. But in the first example, the minifier won't optimise away [CODE]increment[/CODE] or [CODE]decrement[/CODE]. For multiple reasons, object property names can't be safely modified:

  • There's no guarantee that properties attached to [CODE]Model[/CODE] (or n as it is optimised to) won't be referenced from somewhere else
  • The object might be serialised with JSON, in which case its property names must be preserved
  • The behaviour of the program would change if any logic uses [CODE]Object.keys()[/CODE]

The thing to remember here is that property names are preserved. If you want your nice, long, descriptive variable names to be optimised away, then you must only define them in a function scope, not hang them off of an object or class.

Understand transpilation

Lots of codebases use Babel, TypeScript and other tools that take a language like JS as input, parse it, and output browser-compatible JS. I don't need to sing the benefits of these tools, but one of their downsides is that you can inadvertently introduce large chunks of code in your output bundle with just a few characters of nice modern syntax. For example, using the object rest/spread operator:

-- CODE language-javascript --
var z = { ...x };

Transpiling this with Babel in its default configuration results the following output:

-- CODE language-javascript --
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; }

function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }

function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }

var z = _objectSpread({}, x);

To help mitigate this problem, many babel plugins come with a loose mode. In their default configuration they'll produce strictly spec-compliant behaviour, but in loose mode they'll sacrifice full spec compliance in all edge cases in order to produce a smaller output, and this is often good enough. In loose mode, the previous example becomes:

-- CODE language-javascript --
function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }

var z = _extends({}, x);

Which is still a fair chunk! You have to weigh up whether the use of the modern syntax is worth the weight penalty, and that depends on the circumstance.

An example of where it wouldn't make much sense would be if x only had three known properties and the intention is to make a shallow copy. In that case you'd probably be best writing something like this instead:

-- CODE language-javascript --
var z = { a: x.a, b: x.b, c: x.c };

Measure

My final and overarching piece of advice is to measure, and measure repeatedly. Automate it if you can.

Bundle size can be affected in many different ways, often counterintuitively. I have genuinely thrown away modifications I spent ages hacking away at — with the intention of decreasing the bundle size — but which actually ended up either making the code less readable for zero benefit, or worse, actually made the output larger. It's soul destroying to find out, but it's better than the alternative which is to ship it, blissfully unaware!

On the bugsnag-js repo we have a bot that reports the size diff on every PR:

This means we understand immediately the impact any change has on the size of the bundle.

Another tool that has been incredibly valuable is source-map-explorer. This helps find out what the heaviest parts of your bundle are by displaying a visual breakdown of the input files and the size they contributed to the final bundle.

This is what is shows for bugsnag.min.js:

So there we have it. To recap:

  • Make sure everything going in to your bundle is justified
  • Understand the processes your code goes through when bundling
  • Observe the size and the contents of the resulting bundle to understand the impact of changes

And that's all the advice I have for now. I hope this helps you shave a few bytes off of your bundles!

Bugsnag helps you prioritize and fix software bugs while improving your application stability
Request a demo