Split your Webpacker bundles to speed up the web
Matouš Borák
Posted on October 28, 2020
In 2018, about two years ago, we brought webpack to our main Rails project to bundle up our newly written JavaScript code instead of the asset pipeline. With the Webpacker gem and its default settings, this gradual transition was very straightforward as we didn’t have to deal with the infamously complicated webpack configs at all. We learned ES6, began writing Stimulus 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.
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
tool.
To install the tool, we followed the instructions in the Readme. Modern Rails stack includes yarn
so we can use it to install the tool:
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:
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:
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:
The Trix editor – we currently use this rich-text editor only in a certain part of our admin section.
The Dropzone library – this is a drag-and-drop file upload library that we use in various forms but definitely not on most pages.
The d3 library – we use this neat visualization package mainly in the craft prices page 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; 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, 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 (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 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:
// 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“, 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. The old HTTP/1 (or 1.1) protocol has serious limitations 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) for your assets automatically. We ourselves use the CDN77 service. The requests then should look like this in the developer tools (this is Firefox; Chrome shows just "h2"):
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):
// 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 of rendering the Trix editor. As expected, we found only a single place – a form in the admin area (note that we use Slim for our templates but we hope that the syntax here is readable enough for everyone):
// 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 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 in the same way as the main application.js
pack:
// 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:
// 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.
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:
// 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:
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 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).
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 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 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.
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 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:
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:
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 or 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 and on Twitter. Thanks!
Posted on October 28, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.