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:
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:
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:
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.