loading...
Cover image for These Rails apps are overpacking their JavaScript bundles

These Rails apps are overpacking their JavaScript bundles

rossta profile image Ross Kaffenberger Originally published at rossta.net ・12 min read

Originally published at rossta.net

Photo by Jørgen Håland on Unsplash

You might think dividing your JavaScript into multiple bundles will help improve page load performance. When done incorrectly with Webpacker, it's possible to make things worse.

This mistake appears relatively common. As I'll share in this post, I've discovered several of my favorite Rails applications are making browsers download and parse more JavaScript than necessary even while attempting to send less.

I believe Rails developers may think the mechanics of packaging JavaScript for the browsers work similarly in Webpacker as it does with the Rails asset pipeline. This assumption is fraught with peril!

Crash bang warehouse accident

As we'll see, Webpacker is a very different beast than the Rails asset pipeline. We need a different mental model to understand how it works. We should also follow a few basic guidelines to deliver JavaScript correctly and avoid falling victim to "bundle bloat."

First, let's take a little safari and see what we can do to help a few companies correct their Webpacker usage out in the wild.

Help me help them: My aim in highlighting these apps is to educate, not criticize!

If you know anyone who works at any of the following companies, please share this post or ask them to reach out to me at ross at rossta dot net.

Case study: Podia

Podia is a fantastic service that provides content creators with a platform to sell digital content, including e-books, online courses, and webinars.

Podia homepage

We can tell Podia uses Webpacker to bundle assets because it renders a Webpacker manifest at https://app.podia.com/packs/manifest.json:

{
  "admin/ui.css": "/packs/css/admin/ui-59291053.css",
  "admin/ui.js": "/packs/js/admin/ui-931ad01f76a9c8b4c1af.js",
  "admin/ui.js.map": "/packs/js/admin/ui-931ad01f76a9c8b4c1af.js.map",
  "application.js": "/packs/js/application-42b89cd8ec22763d95ae.js",
  "application.js.map": "/packs/js/application-42b89cd8ec22763d95ae.js.map",
//...

The manifest contains URLs to many Webpacker "packs," also described as entry points in the webpack docs.

When I visit the public-facing storefront, my browser downloads the following "packs" from the Podia CDN:

/packs/js/storefront/index-1d9e9c36f5f9ab7056d1.js
/packs/js/storefront/current_time_ago-0c5c682d173647ef3199.js
/packs/js/storefront/messaging-60ddf6567eb7b74a1083.js

By splitting JavaScript up across multiple files on this page, I believe Podia intends to deliver only required JavaScript to the client's browser. For example, there's no need to send JavaScript for the CMS UI to the public-facing storefront page.

As we said earlier, the intent here is good.

The problem

On closer look, though, something doesn't seem quite right. The payloads for these individual packs are, in fact, rather large.

Take the "storefront/current_time_ago.js" bundle. It transfers as 73KB gzipped and comes out to 396KB of parsed JavaScript.

Does Podia's "storefront/current_time_ago" functionality need to be nearly 400KB?

If so, I'd be shocked. I imagine this pack's primary responsibility is similar to the tiny jQuery plugin timeago, which claims a 2KB size. As a comparison, a bundled version of react-dom module parses at around ~150KB.

Something's not right here.

Exploring source maps

I don't work at Podia, so I can't use my favorite tool, the webpack-bundle-analyzer, to peek inside the bundled JavaScript; this requires access to source code.

But, there's another trick we can use. We can find out what's happening inside these bundles from Podia's source maps.

It's like magic.

Corny magician gif

Source maps are included in production by default with Webpacker. You can find the URLs to source maps in the Webpacker manifest file, as shown above.

If you're curious about why source maps in production are enabled by default in Webpacker, you may be interested in this GitHub issue thread.

Another place to find the URL to a source map is in the corresponding source file's last line:

//# sourceMappingURL=application-42b89cd8ec22763d95ae.js.map

We can analyze Podia's publicly available source maps using source-map-explorer. It can provide a visualization of all the modules bundled on this page. Here's an example:

Treemap image example of webpack JavaScript bundles

Podia storefront editor

Here's a screenshot of the source-map-explorer treemap for the three Webpacker packs rendered on the storefront editor page, with my annotations for emphasis:

Treemap image of duplicated JavaScript bundles loaded on Podia's storefront editor

You can see the three JavaScript bundles in purple, blue, and orange, and with each, you can see included modules such as actioncable, stimulus, moment.js, core-js, and cableready.

Here's the problem: some modules appear twice on the same page!

Mr. Bean is shocked

Two bundles include both moment.js and all the 100+ moment-locale modules. That means the browser has to download and parse moment.js (52KB) and moment-locales (326KB) twice on the same page!

Same for actioncable, cableready, stimulus, and core-js.

In an attempt to deliver less JavaScript to the browser with page-specific bundles, they've ended up with even bigger payloads. Podia is "overpacking", and it's resulting in the redundant module problem.

More case studies

It's not just Podia. I've recently discovered several other Rails applications with the same problem.

Funny or Die
Funny or Die home page

I'm always up for a laugh, but you know what's not funny? Duplicate jquery on the Funny or Die home page.

That's an extra 80KB and, I would presume, a potential source of bugs for jquery plugins that assume only one instance of $ in the page scope.

Treemap image of duplicated JavaScript bundles loaded on Funny or Die's home page

Dribbble
Dribbble profile page

I'm whistling the Dribbble profile page for multiple infractions, including duplicate instances of vue and axios. They could reduce their total payload size by up to 150KB.

Treemap image of duplicated JavaScript bundles loaded on Dribbble's profile page

Teachable
Teachable course page

The course page on Teachable must love jquery and lodash. They're both bundled twice across the three Webpacker packs rendered on this page.

Treemap image of duplicated JavaScript bundles loaded on Teachable's student course page

Drizly
Drizly search page

It's raining JavaScript at Drizly! The product search page renders three packs, each of which includes instances material-ui, react, and lodash, among others. If Drizly were to introduce React hooks, I am relatively sure multiple instances of React will cause issues if they haven't already.

Treemap image of duplicated JavaScript bundles loaded on Drizly's profile page

Strava's activity feed
Strava activity feed

As an endurance athlete in my spare time, I use Strava almost daily, where the activity feed forces me to render four instances of react! Strava could reduce their activity feed payloads by a whopping 500KB by getting rid of their duplicated modules.

Treemap image of duplicated JavaScript bundles loaded on Strava's activity feed

Analyzing JavaScript usage

Another tool I recommend is bundle-wizard, which can be used to find unused JavaScript modules on page load.

$ npx -p puppeteer -p bundle-wizard bundle-wizard --interact

This tool turns the source-map-explorer into a heatmap representing code coverage across the bundled modules from high (green) to low (red).

Here are the source maps from the Strava activity feed visualized again with bundle-wizard coverage heatmap:
Bundle wizard treemap with heatmap overlay

See all that red? Those extra React modules are unused on page load.

Measuring end user performance

We can also see whether Google's Lighthouse performance auditing tool would back these findings.

Lighthouse is an open-source, automated tool that can perform web page audits for performance, accessibility, among other quality indicators. You can generate a Lighthouse report for almost any page you can access on the web through Chrome DevTools or a Firefox extension.

I generated this Lighthouse report for my Strava dashboard:

Lighthouse report screenshot for strava.com/dashboard

The page scores 23/100 based on Lighthouse's performance metric scoring rubric, and, by far, the biggest opportunity for improving page load performance is to remove unused JavaScript.

This much is clear: JavaScript bloat is hampering the performance of these Rails apps.

Why the redundant modules?

It should be clear by now some Rails apps using Webpacker are bundling some modules unnecessarily across multiple bundles on a single page. As a result:

  1. JavaScript payloads are larger—not smaller—causing increased download and parse times for the end user
  2. Logic may assume "singleton" behavior or touch global concerns leading to confounding bugs

So why is this happening?

These Rails applications aren't intentionally bundling all this extra JavaScript. The fact that they are splitting up their bundles indicates they are attempting to be selective about what JavaScript is delivered on a given page.

Wait, so we can't split code into multiple bundles without duplicating modules in Webpacker?

Let's be clear that the practice of code-splitting isn't wrong; it's a recommended best practice for improving page load performance.

The problem with these examples is in the execution; it's not happening the way webpack expects.

Consider Cookpad.com. It's a Rails app that renders numerous Webpacker bundles on its home page, yet no modules are duplicated:

Treemap of Cookpad source mapsl

When it comes to Webpacker, the Cookpad recipe is top-notch.

A new mental model

The redundant module problem highlights that although the Rails asset pipeline and webpack solve the same general problem, they do so in fundamentally different ways.

Webpack is a module bundler.

The asset pipeline is a file concatenator.

The asset pipeline builds a list of what the developer explicitly requires. Think of it as a stack. "What you see is what you get."

Stack

Webpack, on the other hand, recursively parses the import statements in all the dependencies within a single pack, such as app/javascript/packs/application.js, like a directed graph.

Directed graph

Webpack will include all imported modules in the output, ensuring that no import is included in the same bundle twice.

If that's true, why are there multiple instances modules in Podia's output, for example?

The reason: each pack is a separate dependency graph.

Consider this illustration of an imaginary project with multiple packs. One pack imports moment explicitly, and the other pack imports a made-up timeago plugin that depends on moment.

Image of Webpacker bundling assets with multiple packs

See that the moment package is imported in both packs. There is an explicit import in the first pack, and an implicit import via timeago in the other.

So splitting your code into multiple packs can lead to this problem if you don't configure webpack properly.

What we want is a way to split code up into smaller pieces without all the overhead and potential bugs. It turns out, webpack was initially created to address precisely this: code-splitting.

It's just done differently than you expect.

The Webpacker Packing Checklist

Now that we know what the problem is and how to diagnose it, what can we do about it?

The key to addressing this kind of Webpacker code bloat is to keep all dependencies in the same dependency graph.

Below, I summarize the steps I would take to help these companies, which you can apply in your applications. These steps are iterative; you need not complete all these actions to begin seeing benefits.

Step 1: Start with one entry point per page

Webpack recommends one entry point per page. From the webpack docs:

As a rule of thumb: Use precisely one entry point for each HTML document.

That's how webpack assumes your application will work out-of-the-box. Practically speaking, that means there would be only one usage of javascript_pack_tag per page:

<%= javascript_pack_tag "application" %>

For the companies described in this post, that would mean consolidating those separate packs into one on a page. Rendering multiple entry points on a single page correctly requires additional configuration. We'll get to that, but "one pack per page" is how I recommend starting.

Does this mean you have to put all your JavaScript in one pack? No, but:

Step 2: Keep the number of packs small

Don't split your JavaScript into a ton of little packs/entry points unless you understand the tradeoffs, and you're comfortable with webpack.

For smaller applications, just an "application.js" may be well worth the tradeoff of having an application that's easier to maintain over the added cost of learning how to best split up JS code with webpack with little performance gain.

Think of packs as the entry points to distinct experiences rather than page-specific bundles.

For Podia, this could be one pack for the public-facing storefront, one for the storefront editor, one for the customer dashboard. Maybe an employee admin area pack. That's it.

Render one pack per page?... Keep the number of packs small? ... these bundles could get huge!

Ok, now we've come to webpack's sweet spot:

Step 3: Use dynamic imports

Webpack has several automated features for code-splitting that will never be supported in the Rails asset pipeline. The primary example of this is dynamic imports.

Dynamic imports allow you to define split points in code rather than by configuration or multiple entry points. Note the import() function syntax:

// Contrived examples

// Import page-specific chunks
if (currentPage === 'storefront') {
  import('../src/pages/storefront')
}

// Import progressive enhancement chunks
if (document.querySelector('[data-timeago]').length) {
  import('../src/initializer/current_time_ago')
}

// Import bigger on-demand chunks following user interaction
document.addEventListener('[data-open-trix-editor]', 'click', () => {
  import('../src/components/trix_editor')
})

In the example above, the imported modules are not separate packs. They are modules included in the same dependency graph but compiled as separate files. Webpack will load dynamic imports asynchronously at runtime.

Dynamic imports allow you to divide your "packs" into smaller pieces while avoiding the redundant module problem.

Does this mean import every little module in little dynamic chunks? No. Measure, experiment. Consider the tradeoffs with handling asynchronous code loading. Timebox your efforts

Step 4: Go further with splitChunks, but only when you're ready

For a more powerful combination, use page-specific dynamic imports combined with the splitChunks configuration API to split out bundles for vendor code that can be shared across packs. In other words, browsers wouldn't have to pay the cost of re-downloading bundles containing moment.js, lodash.js, etc., across multiple pages with a warm cache.

Beware, though; this technique is a bit more advanced. It requires the use of separate Rails helpers, javascript_packs_with_chunks_tag and stylesheet_packs_with_chunks_tag, which will output multiple bundles produced from a single pack and these helpers should only be used once during the page render. It may take some reading up on the webpack docs and some experimentation with the chunking logic to achieve optimal results.

Check out the open-source Forem application (formerly dev.to) for an excellent example of how to do "splitChunks."

Summing up

Webpack can be a bit confusing to understand at first. Webpacker goes a long way toward providing that "conceptual compression" to get developers up-and-running on Rails. Unfortunately, Webpacker doesn't yet offer all the guard rails required to avoid problems like overpacking. As we've seen, some Rails apps are using Webpacker with an asset-pipeline mindset.

Embracing new tools may mean a little more investment, along with letting go of the way we used to do things.

Apply the Webpacker Packing Checklist to ensure a good experience for clients who desire faster webpages and developers who want fewer bugs.

Posted on by:

rossta profile

Ross Kaffenberger

@rossta

I build web apps, most in Ruby and/or JavaScript. I'm lucky to work remotely. I also do triathlon and Dad jokes.

Discussion

pic
Editor guide
 

Thanks, this is a great intro to webpack concepts! Very helpful to have the concrete examples with the dependencies visualized.

 
 

Very informative and clear post, thanks for sharing! 😊