DEV Community

Cover image for Rails is Fast: Optimize Your View Performance
Swaathi Kakarla for AppSignal

Posted on • Originally published at blog.appsignal.com

Rails is Fast: Optimize Your View Performance

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:

# == Schema Information
#
# Table name: people
#
#  id          :integer          not null, primary key
#  name        :string
#  votes_count :integer
#  created_at  :datetime         not null
#  updated_at  :datetime         not null
#

class Person < ApplicationRecord
  # Relationships
  has_many :profiles

  # Validations
  validates_presence_of :name
  validates_uniqueness_of :name

  def vote!
    update votes_count: votes_count + 1
  end
end
Enter fullscreen mode Exit fullscreen mode

This is the code for our Profile model:

# == Schema Information
#
# Table name: profiles
#
#  id         :integer          not null, primary key
#  address    :text
#  person_id  :integer
#  created_at :datetime         not null
#  updated_at :datetime         not null
#

class Profile < ApplicationRecord
  # Relationships
  belongs_to :person

  # Validations
  validates_presence_of :address
end

Enter fullscreen mode Exit fullscreen mode

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.

def home
  @people = Person.all
end
Enter fullscreen mode Exit fullscreen mode

The code for our home.html.erb is as follows:

<ul>
  <% @people.each do |person| %>
    <li id="<%= person.id %>"><%= render person %></li>
  <% end %>
</ul>
Enter fullscreen mode Exit fullscreen mode

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'

...
(0.1ms)  SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ?  [["person_id", 996]]
Rendered people/_person.html.erb (1.5ms)
(0.2ms)  SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ?  [["person_id", 997]]
Rendered people/_person.html.erb (2.3ms)
(0.1ms)  SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ?  [["person_id", 998]]
Rendered people/_person.html.erb (2.1ms)
(0.2ms)  SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ?  [["person_id", 999]]
Rendered people/_person.html.erb (2.3ms)
(0.2ms)  SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ?  [["person_id", 1000]]
Rendered people/_person.html.erb (2.0ms)
Rendered application/home.html.erb within layouts/application (890.5ms)

Completed 200 OK in 1066ms (Views: 890.5ms | ActiveRecord: 175.4ms)
Enter fullscreen mode Exit fullscreen mode

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:

<ul>
  <li>
    Name: <%= person.name %>
  </li>
  <li>
    Addresses:
      <ul>
        <% person.profiles.each do |profile| %>
          <li><%= profile.address %></li>
        <% end %>
      </ul>
  </li>
</ul>

<%= button_to "Vote #{person.votes_count}", vote_person_path(person) %>
Enter fullscreen mode Exit fullscreen mode

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:

def home
  @people = Person.all.includes(:profiles)
end
Enter fullscreen mode Exit fullscreen mode

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.

Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.2ms)
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.2ms)

Rendered application/home.html.erb within layouts/application (936.0ms)
Completed 200 OK in 936ms (Views: 927.1ms | ActiveRecord: 9.3ms)
Enter fullscreen mode Exit fullscreen mode

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.

<li>
  Addresses:
  <ul>
    <% person.profiles.pluck(:address).each do |address| %>
      <li><%= address %></li>
    <% end %>
  </ul>
</li>
Enter fullscreen mode Exit fullscreen mode

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:

# of People: <%= @people.count %>
Enter fullscreen mode Exit fullscreen mode

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.

Progress: <%= index / @people.count %>
Enter fullscreen mode Exit fullscreen mode

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:

def home
  @people = Person.all.includes(:profiles)
  @people_count = @people.count
end
Enter fullscreen mode Exit fullscreen mode

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.

Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
Processing by PeopleController#vote as HTML
  Person Load (0.3ms)  SELECT  "people".* FROM "people" WHERE "people"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
   (0.1ms)  begin transaction
  Person Exists (4.5ms)  SELECT  1 AS one FROM "people" WHERE "people"."name" = ? AND ("people"."id" != ?) LIMIT ?  [["name", "Deon Waelchi"], ["id", 1], ["LIMIT", 1]]
  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]]

Redirected to http://localhost:3000/
Completed 302 Found in 24ms (ActiveRecord: 7.5ms)


Started GET "/" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
Processing by ApplicationController#home as HTML
  Rendering application/home.html.erb within layouts/application

  Rendered people/_person.html.erb (2.4ms)
   (0.3ms)  SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ?  [["person_id", 30]]
  Rendered people/_person.html.erb (2.2ms)
  ...

  Rendered application/home.html.erb within layouts/application (159.8ms)
Completed 200 OK in 190ms (Views: 179.0ms | ActiveRecord: 6.8ms)
Enter fullscreen mode Exit fullscreen mode

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:

<%= button_to "Vote #{person.votes_count}", vote_person_path(person), remote: true %>
Enter fullscreen mode Exit fullscreen mode

Our controller action returns a JS response, which looks like this:

$("#<%= @person.id %>").html("<%= j render(partial: 'person', locals: {person: @person}) %>");
Enter fullscreen mode Exit fullscreen mode

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?

Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:52:56 +0530
Processing by PeopleController#vote as JS

  Person Load (0.2ms)  SELECT  "people".* FROM "people" WHERE "people"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
   (0.1ms)  begin transaction
  Person Exists (0.3ms)  SELECT  1 AS one FROM "people" WHERE "people"."name" = ? AND ("people"."id" != ?) LIMIT ?  [["name", "Deon Waelchi"], ["id", 1], ["LIMIT", 1]]
  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]]
   (1.6ms)  commit transaction
  Rendering people/vote.js.erb
   (0.2ms)  SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ?  [["person_id", 1]]

  Rendered people/_person.html.erb (3.2ms)
  Rendered people/vote.js.erb (6.3ms)
Completed 200 OK in 31ms (Views: 14.6ms | ActiveRecord: 2.9ms)
Enter fullscreen mode Exit fullscreen mode

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,

<% cache do %>
  <ul>
    <% @people.each do |person| %>
      <li id="<%= person.id %>"><%= render person %></li>
    <% end %>
  </ul>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Next, install the Dalli gem and change the cache store in "development.rb" to:

config.cache_store = :dalli_store
Enter fullscreen mode Exit fullscreen mode

Then, if you're on Mac or Linux, simply start the Memcached service like this:

memcached -vv
Enter fullscreen mode Exit fullscreen mode

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!

def team_size
  Rails.cache.fetch(:team_size, expires_in: 8.hour) do
    analytics_client = AnalyticsClient.query!(self)
    analytics_client.team_size
  end
end
Enter fullscreen mode Exit fullscreen mode

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,

person.addresses
Enter fullscreen mode Exit fullscreen mode

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

Guest author Swaathi Kakarla is the co-founder and CTO at Skcript. She enjoys talking and writing about code efficiency, performance, and startups. She’s also the chapter lead for Google Developers Group, Chennai!

Top comments (0)