Analyzing, visualizing and optimizing JS bundle size in Rails/Webpacker apps
Since the moment Rails embraced the JavaScript ecosystem, it has quickly become super easy to be productive with JavaScript toolset. Productivity, the new ecosystem (often unfamiliar to Rails developers specialized in the back-end), and magic of Rails configuration made JS code a big, opaque box that “just works” and doesn't seem to need to be understood. Lack of understanding has consequences, one of which is bloated JavaScript bundle.
In this article, we'll create a Rails app with a simple React component using the most typical stack, analyze the bundle size and try to optimize two common reasons for size inflation.
Creating the project
1rails new optimize_webpack_bundle_size --webpack=react2cd optimize_webpack_bundle_size3rails db:migrate
While Rails with webpacker does support rendering React components out of the box, react-rails gem provides additional helpers, generators, and elegant hooks. Let's add it to the Gemfile
:
1gem 'react-rails'
Next, install the gem:
1bundle install2rails generate react:install
Finally, clean up the project by deleting app/javascript/packs/hello_react.jsx
file and removing console.log('Hello World from Webpacker')
line from app/javascript/packs/application.js
.
Generating React component
We're going to implement a component that displays match results. It will receive match date and score, and will format the date in relative format and pick the winning team.
To make sense of the purpose of this tutorial, let's use external dependencies to pick the winning team (lodash
) and format the relative date (moment
) to inflate our JS bundle a little bit:
1yarn add moment lodash
Time to generate the component:
1rails g react:component MatchResults date:string teamScores:array2mv app/javascript/components/MatchResults.js app/javascript/components/MatchResults.jsx
Our component will be so simple it will not contain any state – so it makes the most sense to implement it as a functional stateless component: not a class but a function taking just one argument: props. Since we already know which properties the component will use, we can destructure the argument.
1import React from "react";2import PropTypes from "prop-types";3import _ from "lodash";4import moment from "moment";56export default function MatchResults({ date, teamScores }) {7 const relativeDateDescription = moment(new Date(date)).fromNow();8 const winningTeam = _.maxBy(teamScores, (t) => t.score);910 return (11 <p>12 {winningTeam.name} won the match {relativeDateDescription}, scoring{" "}13 {winningTeam.score} frags.14 </p>15 );16}1718MatchResults.propTypes = {19 date: PropTypes.string.isRequired,20 teamScores: PropTypes.arrayOf(21 PropTypes.shape({22 name: PropTypes.string.isRequired,23 score: PropTypes.number.isRequired,24 }),25 ).isRequired,26};
Finally, we have to generate a controller to display our component:
1rails g controller pages index
Here's how app/views/pages/index/html.erb
will look like:
1<%= react_component "MatchResults", {2 date: 96.hours.ago.to_date,3 teamScores: [4 {name: "Blue Team", score: 2},5 {name: "Red Team", score: 3}6 ]7} %>
Remember to plug this view to config/routes.rb
:
1Rails.application.routes.draw do2 root to: 'pages#index'3end
Also, include the JS pack in application layout:
1<head>2 ...3 <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>4</head>
Our app is ready to run:
That's a pretty unimpressive, hello-world-like app with two small libraries. Hopefully, the JS bundle ends up being relatively small. Let's check it out.
Visualizing the JavaScript dependencies
Webpack has built-in size profiler and can provide a JSON file with all information we need:
1node_modules/.bin/webpack --config config/webpack/production.js --profile --json
Unfortunately, the resulting file is huge, and it's impossible to analyze it manually. That's why it makes the most sense to visualize this data. We'll do it using webpack-bundle-analyzer:
1yarn add webpack-bundle-analyzer --dev
Now we can generate stats file and point it to the analyzer:
1node_modules/.bin/webpack --config config/webpack/production.js --profile --json > stats.json2node_modules/.bin/webpack-bundle-analyzer stats.json
This operation will open the web browser with a simple application allowing you to analyze the JavaScript dependencies visually. Feel free to scroll and zoom in for greater detail!
Visualizing the dependencies made it evident that moment
and loads
are the main reasons for JS bloat in our simple app. Bundle analyzer states that stat size of application.js is 1.17 MB. Let's try to optimize it.
Optimizing moment.js size
It's quite apparent that locales are responsible for the bloat. Webpack did its best to optimize the bundle size by tree shaking, but it works only for libraries implemented using ES6 modules. Moment.js was written before the concept of ES6 modules even existed so it's no wonder that it doesn't make good use of it and as a result, it eagerly includes all the locales when we import it. If you're looking for a modern, modular, ES6 alternative, try date-fns.
There's not much we can do in our code to optimize the way we import moment
, but Webpack is powerful enough to allow us to ignore specific/all locales. There are two ways to do it.
Using IgnorePlugin
Edit config/webpack/environment.js
and make it look like this:
1const { environment } = require("@rails/webpacker");23const webpack = require("webpack");45environment.plugins.prepend(6 "MomentIgnoreLocales",7 new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),8);910module.exports = environment;
Down to 814 KB, that's about 300 KB smaller. Nice!
With this solution, you can still import the locale you'd like to use. However, you need to do it explicitly, as the locale is no longer loaded automatically. Not ideal, let's proceed to the second solution:
Using ContextReplacementPlugin
Edit config/webpack/environment.js
and make it look like this:
1const { environment } = require("@rails/webpacker");2const webpack = require("webpack");34environment.plugins.prepend(5 "MomentContextReplacement",6 new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en|pl/),7);89module.exports = environment;
This method results in a similar drop in size but you can additionally specify which locales you want to use up-front, so you don't need to worry about it when importing and using the library.
Optimizing lodash.js size
Now lodash
is the only obviously standing out library that needs our attention. Unlike moment.js
, lodash
is written in a modular way. If we import only the functions we use (instead of _
object that contains all functions), Webpack will optimize the bundle and make sure to include only the necessary parts of the library.
Importing specific functions
We could optimize imports in our component so that it could look like this:
1import maxBy from "lodash/maxBy";2// ...3const winningTeam = maxBy(teamScores, (t) => t.score);
This simple operation yielded an amazing improvement: our JS bundle size is down to 396 KB!
Babel plugin
Since this is a toy project, fixing lodash
imports was relatively quick, but a massive project with hundreds of React components does not have this luxury. Fortunately, we can use babel-plugin-lodash to let Babel automatically transform our imports.
1yarn add babel-plugin-lodash
After adding the plugin, all we have to do is declare our intention to use it in .babelrc
file in the project:
1{2 // ...3 "plugins": [4 "lodash",5 "syntax-dynamic-import",6 "transform-object-rest-spread",7 // ...8 ]9}
That's it! The stats will look identical, but we don't have to modify a single line of JavaScript. Letting tools do the heavy lifting is always the preferred option.
As a side note, lodash-es library enables more elegant ES6 imports but you'll still need to hand-pick the functions you'd like to use.
Wrapping up
Tuning Webpack and Babel configuration resulted in JS bundle size dropping from 1.17 MB to 396 KB without changing a single line of code. That's an incredible improvement.
To make sure the process is repeatable and documented, I recommend adding a script to package.json
file:
1{2 "dependencies": {3 // ...4 },5 "devDependencies": {6 // ....7 },8 "scripts": {9 "webpack:analyze": "mkdir -p public/packs && node_modules/.bin/webpack --config config/webpack/production.js --profile --json > public/packs/stats.json && node_modules/.bin/webpack-bundle-analyzer public/packs/stats.json"10 }11}
Now you can easily generate and view the stats by running yarn run webpack:analyze
. Happy tuning!
You can see the final result of this tutorial on GitHub.