In this post, we'll look into tried and true methods of improving Rails view performance. Specifically, I will focus on database efficiency, view manipulation, and caching.
I think the phrase "premature optimization is the root of all evil" has been taken a little out of context. I've often heard developers use this during code reviews when simple optimization techniques are pointed out. You know the famous, "I'll get it working and then optimize it" - then test it - then debug it - then test it again, and so on!
Well, thankfully, there are some simple and effective performance and optimization techniques that you can use from the moment you start writing code.
Throughout the post, we will stick to a basic Rails app, make improvements on it and compare results.
The basic Rails app has the following models:
Person (has many addresses)
- name:string
- votes_count:integer
Profile (belongs to Person)
- address:string
This is what our Person model looks like:
1# == Schema Information
2#
3# Table name: people
4#
5# id :integer not null, primary key
6# name :string
7# votes_count :integer
8# created_at :datetime not null
9# updated_at :datetime not null
10#
11
12class Person < ApplicationRecord
13 # Relationships
14 has_many :profiles
15
16 # Validations
17 validates_presence_of :name
18 validates_uniqueness_of :name
19
20 def vote!
21 update votes_count: votes_count + 1
22 end
23end
This is the code for our Profile model:
1# == Schema Information
2#
3# Table name: profiles
4#
5# id :integer not null, primary key
6# address :text
7# person_id :integer
8# created_at :datetime not null
9# updated_at :datetime not null
10#
11
12class Profile < ApplicationRecord
13 # Relationships
14 belongs_to :person
15
16 # Validations
17 validates_presence_of :address
18end
19
There's also a seed file to populate 1000 people. We can do this with ease by utilizing Faker gem.
We're now going to create an action called "home" in ApplicationController.
1def home
2 @people = Person.all
3end
The code for our home.html.erb is as follows:
1<ul>
2 <% @people.each do |person| %>
3 <li id="<%= person.id %>"><%= render person %></li>
4 <% end %>
5</ul>
Let's do a dry run and measure the performance of our page against this.
That page took a whopping 1066.7ms to load. Not good! This is what we will aim to reduce.
Database Queries
The first step to building a performant application is to maximize resource utilization. Most Rails apps render something from the database onto the views, so let's try to optimize database calls first!
For the purpose of this demonstration, I'm going to use a MySQL database.
Let's look at how that initial load of 1066ms breaks down.
414.7 to execute 'controllers/application_controller#home'
1...
2(0.1ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 996]]
3Rendered people/_person.html.erb (1.5ms)
4(0.2ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 997]]
5Rendered people/_person.html.erb (2.3ms)
6(0.1ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 998]]
7Rendered people/_person.html.erb (2.1ms)
8(0.2ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 999]]
9Rendered people/_person.html.erb (2.3ms)
10(0.2ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 1000]]
11Rendered people/_person.html.erb (2.0ms)
12Rendered application/home.html.erb within layouts/application (890.5ms)
13
14Completed 200 OK in 1066ms (Views: 890.5ms | ActiveRecord: 175.4ms)
519.2 and 132.8 to render "application/home.html.erb" and "people/_person.html.erb" partials.
Did you notice anything weird?
We made one database call in the controller, but every partial makes its own database call as well! Introducing, the N+1 query problem.
1. N+1 Queries
This is a very popular and simple optimization technique—but it deserves the first mention since this mistake is so prevalent.
Let's see what "people/_person.html.erb" does:
1<ul>
2 <li>
3 Name: <%= person.name %>
4 </li>
5 <li>
6 Addresses:
7 <ul>
8 <% person.profiles.each do |profile| %>
9 <li><%= profile.address %></li>
10 <% end %>
11 </ul>
12 </li>
13</ul>
14
15<%= button_to "Vote #{person.votes_count}", vote_person_path(person) %>
Basically, it queries the database for that person's profiles and renders each one out. So it does N queries (where N is the number of people) and the 1 query we did in the controller—thus, N+1.
To optimize this, make use of the MySQL database joins and the Rails ActiveRecord includes functions.
Let's change the controller to match the following:
1def home
2 @people = Person.all.includes(:profiles)
3end
All the people are loaded by 1 MySQL query, and all their respective queries are loaded in another. Bringing N+1 to just 2 queries.
Let's look at how this increases performance!
It took us only 936ms to load the page. You can see below that the "application_controller#home" action does 2 MySQL queries.
1Rendered people/_person.html.erb (0.3ms)
2Rendered people/_person.html.erb (0.2ms)
3Rendered people/_person.html.erb (0.3ms)
4Rendered people/_person.html.erb (0.3ms)
5Rendered people/_person.html.erb (0.3ms)
6Rendered people/_person.html.erb (0.3ms)
7Rendered people/_person.html.erb (0.3ms)
8Rendered people/_person.html.erb (0.2ms)
9
10Rendered application/home.html.erb within layouts/application (936.0ms)
11Completed 200 OK in 936ms (Views: 927.1ms | ActiveRecord: 9.3ms)
2. Load Only What You Will Use
This is how the homepage looks.
You can see we only need the address, nothing else. But in the "_person.html.erb" partial we load the profile object. Let's see how we can make that change.
1<li>
2 Addresses:
3 <ul>
4 <% person.profiles.pluck(:address).each do |address| %>
5 <li><%= address %></li>
6 <% end %>
7 </ul>
8</li>
For a more in-depth look at N+1 queries, read ActiveRecord performance: the N+1 queries antipattern.
ProTip: You can create a scope for this and add it to the "models/profile.rb" file. Raw database queries in your view files aren't of much use.
3. Move All Database Calls to the Controller
Let's say, in the future of this make-believe application, you'd like to display the total number of users on the home page.
Simple! Let's make a call in the view that looks like this:
1# of People: <%= @people.count %>
Okay, that's simple enough.
There's another requirement—you need to create a UI element that displays the page progress. Let's now divide the number of people on the page by the total count.
1Progress: <%= index / @people.count %>
Unfortunately, your colleague doesn't know that you've already made this query and they proceed to make it again and again in the views.
Had your controller looked like this:
1def home
2 @people = Person.all.includes(:profiles)
3 @people_count = @people.count
4end
It would have been easier to reuse already calculated variables.
Though this does not contribute to a direct improvement in page load speeds, it prevents multiple calls to the database from various view pages and helps you prepare for optimizations that you can perform later, such as caching.
4. Paginate Wherever You Can!
Just like loading only what you need, it also helps to only show what you need! With pagination, views render a portion of the information and keep the rest to load on demand. This shaves off a lot of milliseconds! The will_paginate and kaminari gems do this for you in minutes.
One annoyance that this causes is that users have to keep clicking on "Next Page". For that, you can also look at "Infinite Scrolling" to give your users a much better experience.
Avoiding HTML Reloads
In a traditional Rails app, HTML view rendering takes a lot of time. Fortunately, there are measures you can take to reduce this.
1. Turbolinks
This comes wrapped up in your standard Rails app. Turbolinks is a JavaScript library that works everywhere (even without Rails, like on static pages) and degrades gracefully on unsupported browsers.
It converts every link into an AJAX request and replaces the entire body of the page via JS. This greatly improves performance as it doesn't have to reload the CSS, JS and images.
However, when writing custom JS you'll have to take extra precaution to write "Turbolinks safe JS". Read more about this here.
2. Use AJAX Requests
In the same vein as Turbolinks, you can convert some of your links and buttons into AJAX requests as well. The difference here is that you get to control what HTML gets replaced rather than replacing the whole body as Turbolinks does.
Let's see AJAX in action!
In the sample app, there's a "Vote" button for each user. Let's measure how long it takes to do that action.
1Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
2Processing by PeopleController#vote as HTML
3 Person Load (0.3ms) SELECT "people".* FROM "people" WHERE "people"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
4 (0.1ms) begin transaction
5 Person Exists (4.5ms) SELECT 1 AS one FROM "people" WHERE "people"."name" = ? AND ("people"."id" != ?) LIMIT ? [["name", "Deon Waelchi"], ["id", 1], ["LIMIT", 1]]
6 SQL (1.0ms) UPDATE "people" SET "votes_count" = ?, "updated_at" = ? WHERE "people"."id" = ? [["votes_count", 1], ["updated_at", "2020-01-21 09:20:49.941928"], ["id", 1]]
7
8Redirected to http://localhost:3000/
9Completed 302 Found in 24ms (ActiveRecord: 7.5ms)
10
11
12Started GET "/" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
13Processing by ApplicationController#home as HTML
14 Rendering application/home.html.erb within layouts/application
15
16 Rendered people/_person.html.erb (2.4ms)
17 (0.3ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 30]]
18 Rendered people/_person.html.erb (2.2ms)
19 ...
20
21 Rendered application/home.html.erb within layouts/application (159.8ms)
22Completed 200 OK in 190ms (Views: 179.0ms | ActiveRecord: 6.8ms)
That took the same amount of time as reloading the page, plus a little extra for the actual voting part.
Let's make it an AJAX request. Now, our "people/_person.html.erb" looks like this:
1<%= button_to "Vote #{person.votes_count}", vote_person_path(person), remote: true %>
Our controller action returns a JS response, which looks like this:
1$("#<%= @person.id %>").html("<%= j render(partial: 'person', locals: {person: @person}) %>");
As you can see, we're replacing only the content we need. We provide an HTML ID to hook onto a div and replace it. Of course, we can further optimize this by replacing only the button content, but for the purposes of this post, let's replace the entire partial.
Results?
1Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:52:56 +0530
2Processing by PeopleController#vote as JS
3
4 Person Load (0.2ms) SELECT "people".* FROM "people" WHERE "people"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
5 (0.1ms) begin transaction
6 Person Exists (0.3ms) SELECT 1 AS one FROM "people" WHERE "people"."name" = ? AND ("people"."id" != ?) LIMIT ? [["name", "Deon Waelchi"], ["id", 1], ["LIMIT", 1]]
7 SQL (0.4ms) UPDATE "people" SET "votes_count" = ?, "updated_at" = ? WHERE "people"."id" = ? [["votes_count", 2], ["updated_at", "2020-01-21 09:22:56.532281"], ["id", 1]]
8 (1.6ms) commit transaction
9 Rendering people/vote.js.erb
10 (0.2ms) SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ? [["person_id", 1]]
11
12 Rendered people/_person.html.erb (3.2ms)
13 Rendered people/vote.js.erb (6.3ms)
14Completed 200 OK in 31ms (Views: 14.6ms | ActiveRecord: 2.9ms)
30ms! That's it! How great is that?
ProTip: If you don't want to mess around with a bunch of HTML IDs and classes to figure out when/what to replace, consider using the render_async gem. It does a lot of the heavy lifting out of the box.
3. Use Websockets
One of the great things about an HTML reload is that it gets you fresh content from the server every time. With an AJAX request, you only see the latest content for the little snippet.
WebSockets are a great piece of technology that let your server push updates to the client, instead of the client requesting for new information.
This can be useful when you need to build dynamic webpages. Imagine you need to display the score of a game on your website. To fetch new content you can,
- Tell your users to reload the entire page
- Provide a reload button that refreshes just the score
- Use JavaScript to keep polling the backend every second
- This will keep pinging the server even when there is no change in data
- Each client will make calls every second - easily overwheling the server
- Use WebSockets!
With WebSockets, the server has control of when to push data to all clients (or even a subset). Since the server knows when data changes, it can push data only when there is a change!
Rails 5 released ActionCable, which lets you manage all things WebSockets. It provides a JS framework for the client to subscribe to the server and a backend framework for the server to publish changes. With action cable, you have the ability to choose any WebSocket service of your choice. It could be Faye, a self-managed web socket service, or Pusher a subscription service.
Personally, I'd choose a subscription for this, as it reduces the number of things you need to manage.
Okay, back to WebSockets. Once you're done setting up ActionCable, your view will not be able to listen to JSON input from the server. Once it receives it, the hook actions you've written will replace the respective HTML content.
Rails docs and Pusher have great tutorials on how to build with WebSockets. They're must-reads!
Caching
The majority of load time gets used up in rendering views. This includes loading all CSS, JS and images, rendering out HTML from ERB files and more.
One way to reduce a chunk of the load time is to identify parts of your application that you know will stay static for some amount of time or until an event occurs.
In our example, it's obvious that until someone votes, the home page will essentially look the same for everyone (currently there is no option for users to edit their addresses). Let's try to cache the entire "home.html.erb" page until an event (vote) occurs.
Let's use the Dalli gem. This uses Memcached to quickly store and retrieve fragments of information. Memcached does not have a datatype for storage, leaving you to store essentially whatever you like.
1. Caching Views
The load time for 2000 records without caching, is 3500ms!
Let's cache everything in "home.html.erb". It's simple,
1<% cache do %>
2 <ul>
3 <% @people.each do |person| %>
4 <li id="<%= person.id %>"><%= render person %></li>
5 <% end %>
6 </ul>
7<% end %>
Next, install the Dalli gem and change the cache store in "development.rb" to:
1config.cache_store = :dalli_store
Then, if you're on Mac or Linux, simply start the Memcached service like this:
1memcached -vv
Now let's reload!!
That took about 537ms! That's a 7x improvement in speed!
You'll also see that there are far less MySQL queries because the entire HTML was stored in Memcached and read from there again, without ever pinging your database.
If you pop on over to your application logs, you'll also see that this entire page was read from the cache.
This example of course is just scratching the surface of view caching. You can cache the partial rendering and scope it to each person object (this is called fragment caching) or you can cache the entire collection itself (this is called collection caching). Further for more nested view rendering, you can perform Russian Doll caching.
2. Caching Database Queries
Another optimization you can do to improve view speed is to cache complex database queries. If your application shows stats and analytics, chances are that you are performing a complex database query to calculate each metric. You can store the output of that into Memcached and then assign a timeout to it. This means that after the timeout, the calculation will be performed again and then stored to the cache.
For example, let's assume that the application needs to display the size of a users team. This could be a complex calculation involving counts of direct reportees, outsourced consultants and more.
Instead of repeating the calculation over and over again, you can cache it!
1def team_size
2 Rails.cache.fetch(:team_size, expires_in: 8.hour) do
3 analytics_client = AnalyticsClient.query!(self)
4 analytics_client.team_size
5 end
6end
This cache will auto-expire after 8 hours. Once that happens, the calculation will be performed again and the latest value will be cached for the next 8 hours.
3. Database Indexes
You can also speed up queries by using indexes. A simple query to fetch all addresses of a person,
1person.addresses
This query asks the Address table to return all addresses where person_id
column is person.id
. Without indexes, the database has to inspect each row individually to check if it matches person.id
. However, with indexes, the database has a list of addresses that match a certain person.id
.
Here's a great resource to learn more about database indexes!
Summary
In this post, we explored how to improve your Rails app's view performance by making improvements to database utilization, using third-party tools and services and restricting what users see.
If you are looking to improve your app's performance, start out simple and keep measuring as you go along! Clean up your database queries, then create AJAX requests wherever you can, and finally cache as many views as possible. You can move on to WebSockets and database caching after that.
However, be cautious—optimization is a slippery slope. You might find yourself as addicted as me!
P.S. For monitoring the performance of your Rails app in production, check out AppSignal's APM - built by Ruby devs for Ruby devs. 🚀