Slicing up Rails Application.js for Faster Load Times

Oli Peate

When using a fast device it’s easy to forget that page load times are impacted by not just network speed but processing power too. Browsing the thoughtbot website on a MacBook Pro with a fast internet connection and warm cache feels snappy. Backed by a CDN, the homepage usually loads in under a second for me.

On less powerful devices with slow internet connections the page load times have plenty of room for improvement. To assess this we can use Chrome dev tools to throttle the network and CPU.

Profiling with Chrome dev tools

Here’s a good starting point for profiling page load times with Chrome:

  • CPU throttling: “Low end device (5x slowdown)”
  • Network throttling: “Good 3G”
  • “Disable cache” selected

If you’re profiling the performance against a local instance of your Rails application it’s worth noting the configuration should be as close to production mode as feasible. That means disabling debug assets (so concatenation and compression take place) plus eager loading & caching of code.

With these settings applied, in the Network panel it was evident that the application.js file was taking a long time to load:

Transfer time of application.js, showing that it takes the duration of nearly
the entire page
load.

The application.js file had a 213KB transfer size, which represents the compressed “over the wire” size. This file alone took over six seconds with the simulated network conditions. By contrast it’s possible to solve a Rubik’s cube in under five seconds . Any page loads over three seconds have a 55% increase in bounce rate according to our analytics.

It’s not just the transfer time which is problematic. The uncompressed size of the JavaScript file was 786KB. That’s a sizeable quantity of JavaScript to evaluate. Switching to the Timeline panel and refreshing the page revealed the following:

Evaluation time of application.js, occupying the main thread of the browser
for
468ms.

With the CPU throttling active it takes almost 500ms to evaluate the script. Inspecting the JavaScript manifest file revealed what’s building up the payload:

//= require_self
//= require modernizr-2.7.1-custom
//= require jquery
//= require jquery_ujs
//= require velocity
//= require velocity.ui
//= require d3
//= require moment
//= require moment-timezone
//= require iframeResizer.min
//= require_tree .

The largest contributions were from third-party libraries such as d3.js and moment.js. Fortunately these libraries are only used on two pages located deeper in the site, making them ideal candidates to be removed from the main JavaScript file.

The extracted libraries can still be combined as a single page-specific script so only one additional HTTP request is made on the relevant pages. The upside of this change is significant — the application.js file is now 52KB when compressed:

application.js Before After Change
transfer size 213KB 52KB -76%
content size 786KB 159KB -80%

In turn this reduced the transfer and evaluation times of the JavaScript file. With the same throttling applied:

  • Overall homepage load times decreased 7.5% (from 7.38s to 6.83s on average).
  • application.js evaluation time decreased 66.5% (from 516ms to 173ms on average).

How to add page-specific JavaScript in Rails

One of the most straightforward techniques I’ve found for introducing page-specific JavaScript with the Rails asset pipeline is to use subdirectories. Javascript assets can be organised like this:

Rails JavaScript assets structure with page-specific content in subdirectories
containing an index.js
file.

Leverage require_directory in manifests

The main application.js and index.js files can include any vendor scripts declaratively and use the require_directory directive from Sprockets to include the sibling scripts located in the same directory as the manifest.

// Example admin/index.js:
//
//= require_self
//= require vendor_script_a
//= require vendor_script_b
//= require_directory .

The require_directory directive is useful if you want to avoid declaring each of your required JavaScript files in the manifest.

Assets precompile

Next step is to ensure your new JavaScript files are precompiled by the asset pipeline. Editing config/initializers/assets.rb:

Rails.application.config.assets.precompile += %w(
  admin/index.js
  blog/index.js
)

Include a script tag for the new asset

On the page or layout where you’d like to include the new files add a script tag (this example uses HAML):

= javascript_include_tag "admin" # This references admin/index.js

By naming each of our new manifests index.js the asset is referenced by the directory name. In this example admin/index.js is referenced as admin. This leverages the Sprockets index files feature.

The indirect referencing of index files by directory name could be harder to reason about for unfamiliar contributors to your codebase. If you’d rather avoid this convention (or ‘magic’) then an alternative is to use references with paths. A file called admin/admin.js would be referenced as admin/admin (N.B. the second step of asset precompilation needs to match this too).

Test what matters

What’s to stop a future developer from unwittingly undoing this performance optimisation by adding a require_tree directive to the application.js file?

When performance matters then it must be treated as a first class citizen like any functional changes to the code. That means we should test it!

Rather than a direct performance measurement, which would vary according to the hardware the tests are run on, it’s simpler in this scenario to check the page-specific JavaScript isn’t present elsewhere. This style of spec does fall on the side of testing implementation detail rather than outcome, but it’ll catch regressions nonetheless.

Using Capybara with the capybara-webkit driver (other JavaScript-enabled drivers work too) we can write a feature spec which checks the libraries are no longer included globally:

describe "Assets", type: :feature, js: true do
  describe "application.js" do
    it "does not include page-specific content" do
      visit root_path

      expect("d3").to be_undefined_in_js
      expect("moment").to be_undefined_in_js
    end
  end

  private

  RSpec::Matchers.define :be_undefined_in_js do
    match do |actual|
      evaluate_script("typeof #{actual} === 'undefined'")
    end

    failure_message do |actual|
      "expected JS not to include #{actual}"
    end
  end
end

Now any regression will be caught. If the developer working on the code needs further context they can find the commit which introduced the spec to trace why the JavaScript is modularised.