Split your Webpacker bundles to speed up the web
Tags: webperf, rails, webpack, javascript
Published: 2020-10-28 14:26:25

In 2018, about two years ago, we brought [webpack](https://webpack.js.org) to our main Rails project to bundle up our newly written JavaScript code instead of the [asset pipeline](https://guides.rubyonrails.org/asset_pipeline.html). With the [Webpacker gem](https://github.com/rails/webpacker) and its default settings, this gradual transition was very straightforward as we didn’t have to deal with the [infamously complicated](https://www.reddit.com/r/javascript/comments/7qdksl/having_a_hard_time_understanding_webpack/) webpack configs at all. We learned ES6, began writing [Stimulus](https://stimulusjs.org/) controllers and handling our JS code became quite pleasant again.

Some time ago we noticed that our main production Webpacker bundle, `application.js`, is surprisingly big, over 540 kB (minified, unzipped), that seems like a lot for our Stimulus controllers! It even grew so large that it became the single largest JS bundle that our web pages parsed, bigger than Facebook stuff! 😬 So we looked into it.

![Webpacker bundle as the largest JS file](https://dev-to-uploads.s3.amazonaws.com/i/bfost4niwz1bx92a3nln.png)

### Visualizing webpack bundle contents

Luckily, webpack provides metrics data about the JS bundles it generates and this can be nicely visualized with the [`webpack-bundle-analyzer`](https://github.com/webpack-contrib/webpack-bundle-analyzer) tool.

To install the tool, we followed the [instructions](https://github.com/webpack-contrib/webpack-bundle-analyzer#install) in the Readme. Modern Rails stack includes `yarn` so we can use it to install the tool:

```bash
yarn add -D webpack-bundle-analyzer
```

(the `-D` means this is a development-only dependency and it’s not going to be available in your application JS code).

Next, we ran the following two commands to get a beautiful **tree-map of our Webpacker bundles**:

```bash
NODE_ENV=production bin/webpack --profile --json > tmp/webpack-stats.json

npx webpack-bundle-analyzer tmp/webpack-stats.json public/packs
```

The first command tells Webpacker to produce size metrics data while compiling the bundles and store this info to the JSON file. Setting the `NODE_ENV` variable to `production` is important as, without it, Webpack would generate _development_ bundles which are typically not minified or otherwise optimized. We **always need to view production bundles**.

The second command creates a zoomable tree-map from the JSON file and opens it in your browser. If you have multiple packs configured in Webpacker, you will see all of them in the tree-map. This is great for an initial overview and for cross-bundle optimizations (e.g. extracting shared dependencies out of the bundles), but this time we wanted to **focus on optimizing a single bundle** instead, so we right-clicked the `application.js` file in the tree-map and clicked _"Hide all other chunks"_. And this is what we saw:

![Initial webpack analysis](https://dev-to-uploads.s3.amazonaws.com/i/jy0dw2bpeih1ljvctok4.png)

We looked at the image, at the big bold **”trix.js“** label, and immediately realized what’s wrong - the **bundle includes JS dependencies that are definitely not used very much on our site**!

### Is everything in the bundle really needed?

Upon closer investigation, we quickly determined three dependencies that are the most problematic in our main production JS bundle:

![Problematic areas in the main bundle](https://dev-to-uploads.s3.amazonaws.com/i/3wv6jroaxyvsyq4qijt5.png)

1. **[The Trix editor](https://trix-editor.org/)** – we currently use this rich-text editor only in a certain part of our admin section.

2. **[The Dropzone library](https://www.dropzonejs.com/)** – this is a drag-and-drop file upload library that we use in various forms but definitely not on most pages.

3. **[The d3 library](https://d3js.org/)** – we use this neat visualization package mainly in the [craft prices page](https://www.nejremeslnici.cz/ceniky-remesel) and a few other places.

What is left in this image is **our own application code** (Stimulus controllers) and the **Stimulus dependency itself**. (An attentive reader may notice that we also missed the [awesomplete library](https://projects.verou.me/awesomplete/); we may separate it from the bundle some time later…)

Now, the essence of this optimization is to **split the single large bundle into multiple smaller ones and use each of them only in places where they are actually needed**. A typical web page from our site will then include only the main bundle, `application.js`, which will be considerably smaller now, and other pages will include a couple bundles at once.

Serving smaller JS bundles is very significant as there are fewer bytes for the browser to download on your first visit and **less JS code to parse and execute on _each_ page visit**. (In case you use [Turbolinks](https://github.com/turbolinks/turbolinks), however, JS is parsed and run only the _first_ time it is included in a page and we’ll try to write about our ongoing transition to Turbolinks some other time.) Anyway, both of these aspects **make the page faster**.

Of course, the division line is arbitrary. If you’re brave enough, you can split the bundle further, perhaps even into individual Stimulus controllers, and use them only on the corresponding pages but that might turn out quite cumbersome to maintain. As always, it’s a **compromise between the level of the optimization and developer’s convenience**. We decided to isolate the three bundles described above from the main pack for now.

### How did we even get here?

How did we even end up having such a large bundle? No need to blame anyone, it is actually very easy for little-used dependencies to sneak into your production bundles.

Suppose you want to bring [ActionText](https://edgeguides.rubyonrails.org/action_text_overview.html) (the Trix editor) into your Rails project. You have a page or two to build that would certainly benefit from having the rich-text editor. With excitement, you read about its nice features and are eager to try it. At the same time you can only have a very foggy idea about its relevance for the users (will they use it?) as well as maintenance costs (will there be a lot of issues with it?). You need to be prepared that it turns out not as useful as you imagined initially. Due to all this, you need to get it up-and-running fast.

So you open the official guide and find that the [installation](https://edgeguides.rubyonrails.org/action_text_overview.html#installation) is very easy, ”just run the `bin/rails action_text:install` and that’s it“. After you do that, you may notice, among other things, that the following imports were added to your **main Webpacker bundle**:

```js
// app/javascript/packs/application.js
require("trix")
require("@rails/actiontext")
```

Apparently, Rails (as equally seen in many other Rails architectonic decisions) **favors convenience over performance**. [”Make it work, make it right, then make it fast“](http://wiki.c2.com/?MakeItWorkMakeItRightMakeItFast), remember? We find this approach perfectly OK, it indeed _is_ convenient and enables you to quickly test the thing in reality which is very important if you cannot foresee the outcome precisely (you can’t, usually). What is easy to miss at the same time is that you’ve just added a huge (~240 kB minified, unzipped) JS dependency to your main bundle, i.e. **to _all_ of your pages, and have slowed them down all**, even those which never use the editor… And it’s too easy to forget to get back to ”making it fast“ later on.

### Any words of caution before splitting?

We’d rather call this a prerequisite but yes: we wouldn’t recommend splitting unless your assets are served using the [HTTP/2 protocol](https://en.wikipedia.org/wiki/HTTP/2). The old HTTP/1 (or 1.1) protocol has [serious limitations](https://stackoverflow.com/a/30064610/1544012) in terms of the maximum connections per a single server or domain. If you split your bundle into too many small files, you might end up slowing the download under HTTP/1 instead! On the other hand, **HTTP/2 supports full multiplexing** so all files are transmitted via a _single_ connection to the server, at the same time.

This brings such a benefit that if you still don’t have HTTP/2 set up on your server, be sure to invest some time and energy into configuring your nginx / Apache or buy some CDN service, first. CDNs usually provide HTTP/2 (or even the fresh new [HTTP/3](https://http3.net/)) for your assets automatically. We ourselves use the [CDN77](https://www.cdn77.com/) service. The requests then should look like this in the developer tools (this is Firefox; Chrome shows just "h2"):

![Serving bundles via HTTP/2](https://dev-to-uploads.s3.amazonaws.com/i/xcr08t4l7oobpxcxd1um.png)

### OK, let’s go!

We’ll show the procedure on two examples – how we separated the Trix editor and the Dropbox library.

#### Separating the Trix editor

First, we opened the main Webpacker pack and moved the imports related to the Trix editor out of the file **into a new pack file** called `trix.js` (contrary to the official docs we use imports in the ES6 style instead of the CommonJS ”requires“ style but the effect should be the same):

```diff
// app/javascript/packs/application.js
- import "trix"
- import "@rails/actiontext"

// app/javascript/packs/trix.js
+ import "trix"
+ import "@rails/actiontext"
```

With this single change we removed the big dependency from all of our pages, neat! Next, we needed to find the relevant places to re-add it again. In this case, it was very easy, we just searched through our project to find occurrences of `rich_text_area` which is the [ActionText way](https://edgeguides.rubyonrails.org/action_text_overview.html#examples) of rendering the Trix editor. As expected, we found only a single place – a form in the admin area (note that we use [Slim](http://slim-lang.com/) for our templates but we hope that the syntax here is readable enough for everyone):

```slim
// app/views/admin/content_pages/_form.html.slim
...
= form.input :title, required: true, ...
= form.rich_text_area :content   # <-- this is the line

= form.button :submit, "Save"
```

Now, how do we add the `trix.js` pack specifically to this page? We need to add it to the `<HEAD>` section of the page, somewhere near the place where the main `application.js` pack is included. And that’s what the **[`content_for` helper](https://api.rubyonrails.org/classes/ActionView/Helpers/CaptureHelper.html#method-i-content_for)** is very suitable for. Using this helper, we can **define the JS inclusion code in this form template, but make it render in the main layout file** where the `<HEAD>` page section is generated.

We added the following `content_for` block to the beginning of the form template partial. We named it the `:priority_blocking_js` as that’s what it actually is – JS code that gets included in the page HEAD and is thus [high priority and blocks the browser](https://addyosmani.com/blog/script-priorities/)  in the same way as the main `application.js` pack:


```slim
// app/views/admin/content_pages/_form.html.slim
- content_for :priority_blocking_js
  = javascript_pack_tag "trix"

...
```

> _Note that here we didn’t use any of the attributes that reduce JS parser blocking in the browser (`async` / `defer` / `preload`). We may return to these optimization techniques in a future post._

Then we had to make the `content_for` render in the `<HEAD>` of the page. We opened the **main application layout file** and added the following line:

```slim
// app/views/layouts/application.html.slim
html lang="cs"
  head
    ...
    = yield :priority_blocking_js   # <-- add this line
    = javascript_pack_tag "application"
    ...
```

We added the `yield` right above including the main Webpacker bundle, the `application.js` pack. That makes sense since we added _dependencies_ of our main application JS code to the `yield`-ed block.

Now, these two changes alone allow us to ensure that the Trix dependency is **not** included in any pages where it isn’t needed. In case we want to add the rich-text editor to a new page, we just make sure we add the `content_for` with the `trix.js` pack, too.

By the way, this technique should be **perfectly compatible with Turbolinks**. We will proof-test this soon (we hope!) but we see no obstacles here: the new JS bundle will be recognized and loaded by Turbolinks the first time you visit a page that includes it. When you return to such a page later on, Turbolinks should recognize that it’s the same JS resource, and do nothing.

#### Separating a more abundant dependency (Dropzone)

There is still one potential problem with this setup, though – you must make sure that you **don’t include the same JS pack file multiple times** when rendering your views, otherwise it would get into the page `<HEAD>` section more than once, too! While this wasn’t a problem with the Trix editor, we hit this issue with our more commonly used JS dependency, the [Dropzone library](https://www.dropzonejs.com/). 

As we stated above, we use this library for our file uploads and this functionality is spread in various forms all over the project. We cannot simply add the newly isolated `dropzone.js` pack to the template partial that renders the dropzone element itself, because sometimes we use more dropzones on a single page.

Well, more precisely, we _can_ do it but only with the following little trick that we used when adding the `content_for` in the dropzone partial template: 

```slim
// app/views/shared/_dropzone.html.slim
- unless @_webpack_dependencies_dropzone
  - @_webpack_dependencies_dropzone = true
  - content_for :priority_blocking_js
    = javascript_pack_tag "dropzone"

...
```

This slightly modified version of the `content_for` block ensures that it is called only once per page. We use the `@_webpack_dependencies_dropzone` variable to **memoize that we already added the dropzone pack to the `content_for`** (this is made possible by the fact that the `@`-variables are global in the whole view context). The leading underscore (`@_`) is just our convention to denote that this is an ”internal view variable“, not defined in any controller or anywhere else. We are sure we could even make a small helper method that would handle this advanced `content_for` for us.

### The outcome of this optimization

So what are the results of this effort? First of all, let’s employ the `webpack-bundle-analyzer` tool again to see where we got with the pack files:

![The split application pack with separate dependencies](https://dev-to-uploads.s3.amazonaws.com/i/5edvxx4u48gdxuh2mufw.png)

When you compare this image to the original one above, you’ll notice that the main JS bundle now occupies **less than half of its initial space** and the dependencies are now in three separate bundles, just as we wanted.

**Regarding the byte size** of the bundles, the change is very prominent – let’s have a look at the **”Parsed size“** as shown by the tool (this size usually [corresponds](https://github.com/webpack-contrib/webpack-bundle-analyzer#size-definitions) to the size of the minified, unzipped JS code, i.e. to the code that the browser parses right after downloading it from the production server).

![Bundles size optimization effect](https://dev-to-uploads.s3.amazonaws.com/i/twnthllfvwo5ubzbay9n.png)

Oh that is nice, our main JS bundle shrank from ~540 kB to about 220 kB, that is about a **60% size reduction**! Of course, if you sum up the sizes of all the separate bundles, you’ll get around the same size as before but the point is clear – we rarely, if ever, include all of the bundles on a single page.

Finally, let’s have a look how this optimization affects the **front-end performance of our pages**. Nowadays, this is usually measured with the [Lighthouse](https://developers.google.com/web/tools/lighthouse/) tool by Google. Even better if you use a front-end monitoring service that tests your pages automatically and continually for a long time. We like to use **[DebugBear](https://www.debugbear.com)** for this purpose so let’s look at its dashboards.

What you see in the below image is a comparison of two DebugBear tests of the mobile version of our homepage, before and after we deployed the optimization.

![Front-end effect of the optimization](https://dev-to-uploads.s3.amazonaws.com/i/87k1bqjicz3q5g81nglb.png)

We can see that the page load shrank by about 75 kB, **the Lighthouse performance score jumped up by about 7 points** and the First contentful paint and Largest contentful paint [metrics](https://web.dev/lighthouse-performance/#metrics) dropped by about half a second.

The decreased page load is indeed done by the smaller JS bundle as is evident in the request list:

![Smaller JS bundle](https://dev-to-uploads.s3.amazonaws.com/i/vegc884y8608qi2gr0mh.png)

And, finally, the optimization should be most prominent in the ”Remove unused JavaScript“ hint in Lighthouse, so let’s have a look at that:

Unused JS before optimization:
 ![Unused JS before optimization](https://dev-to-uploads.s3.amazonaws.com/i/c14rt1myc95pr0xjajvn.png)

Unused JS after optimization:
![Unused JS after optimization](https://dev-to-uploads.s3.amazonaws.com/i/ov5fn7e55576byjzaggp.png)

The dependencies (Trix, Dropzone, d3) are indeed gone and this Lighthouse score moved up substantially. Nice!

And the best part is that **with this single optimization we managed to speed up all our pages at once**! We are really satisfied with the result.

### What’s next?

This is all nice but it won’t last forever. We think that a similar analysis should be done **periodically**, once in a few months or so, to catch issues with the growing JS bundles.

Another great way to help with this issue long-term might be setting [performance budgets](https://www.debugbear.com/docs/performance-budgets) or [bundle size monitoring](https://www.debugbear.com/docs/bundle-size-monitoring) in your front-end testing service, and perhaps even integrating it with your Continuous Integration process.

_Front-end optimizations are hard; there are so many factors that come into play all the time! Please, feel free to comment on your attempts to use this technique and we wish you good luck with your optimizations. Front-end speed is currently in our mid-term focus so we’ll try to share some more experiences later._

_Want to read more stuff like this? Please follow me [here](https://dev.to/borama) and on [Twitter](https://twitter.com/boramacz). Thanks!_