This post was updated on 4 August 2023. The section on Turbolinks and AJAX now references Hotwire and StimulusJS and a 'Why optimise for Rails performance?' section has been added.
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.
Why Optimize For Rails Performance?
Before we get into the techniques of optimizing Rails performance, it's important to understand why we need to do so. There are many reasons for optimizing the performance of a Rails app, such as:
- Improved user experience - As we all know, a performant application provides a great user experience.
- Better resource utilization - Having a well-optimized Rails application could lead to cost savings on things like server resources.
- Scalability - Simply put, an optimized Ruby on Rails application can scale better than one that is not.
- Competitive advantage - In today's competititve landscape, having a web application that performs better than the competition can mean making more money and having happier customers.
Of course this list is not exhaustive, but you get the idea of why it's so important to have a performant Rails app. Now, let's get on with how we can actually achieve better performance for our Rails apps.
đź‘‹ If you like this article, take a look at other Ruby (on Rails) performance articles in our Ruby performance monitoring checklist.
Throughout the post, we will stick to a some basic Rails app examples which we'll make improvements on.
Our first 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:
This is the code for our Profile model:
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.
The code for our home.html.erb is as follows:
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)
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. Eliminate 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:
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:
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.
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.
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:
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.
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:
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.
5. 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.
5a. Use Hotwire
Hotwire is an alternative approach to building modern single page applications without using JavaScript. It does this by sending "HTML over the wire". It's composed of several frameworks to help you build reactive applications which avoid HTML reloads. In this section, we'll look into using Hotwire's frameworks for building reactive Rails apps using an example Todo list Rails 7 app. The code repo for this example app is available here.
To get started, initialize a new Rails 7 app:
Quick note: We are using Tailwind CSS for styling but you can use whatever you are comfortable with.
Once the app finishes installing, open it up in your favorite editor then create a new resource called Todo
in the terminal:
Next, migrate the database to create the todos
table:
Then open up the todos_controller.rb
and modify it like so:
And remember to add a corresponding view as well. We'll use something very basic:
Next, we add a form partial we've referenced in the index view for creating Todo's:
Also modify routes.rb
to create a root route:
With that, we have a basic Todo app for our needs. Now test the app by running bin/dev
and creating a few todos.
The first thing you should notice is the app is very fast. It also doesn't feel like redirects occur from creating a todo and going back to the todos list on the same index page, because the transition is so fast, right? Well, that's because in Rails 7, Turbo Drive (which took over from Turbolinks) is in control and intercepting link clicks, form submissions etc. to make full page reloads a thing of the past.
Turbo Drive is part of the Hotwire framework, alongside Turbo Frames, Turbo Streams, and Stimulus.
So as not to make this entire article about Hotwire, we'll only take a look at an example where we use Stimulus to add reactivity to our Todo app. For Turbo Frames and Turbo Streams, you can explore the excellent documentation on how they are used.
Stimulus
Stimulus is described as a "modest JavaScript framework that is not trying to take over your entire front-end", but instead, it adds just the right amount of JavaScript to your already existing HTML to make your app reactive.
We'll modify our Todo app, using Stimulus to mark todos as complete or not. If a todo completes, it should appear in another completed todos list, with a strike through the text.
First, we'll need to modify the index.html.erb
page. Specifically, we'll place a checkbox next to a todo's description giving users the option to mark a todo as "completed". We do that by wrapping each todo in a form_with
element like so:
Next, let's add Stimulus. Add a data-controller
to the parent div of the elements you want to change, like so:
Then generate a new Stimulus controller to connect to the view. Notice that it has the same name as what we've indicated in the view:
Which will generate an app/javascript/controllers/todos_controller.js
Stimulus controller and content similar to the code below:
Before editing this file further, let's first modify the checkbox in the todos list by adding a data
attribute as shown below:
Next, we need to define the toggle
method for the checkbox's data action in the todos Stimulus controller:
In a nutshell, this is what's going on:
- First, we pass an event -
e
- corresponding to the checkbox being checked in thetoggle
method of the todos Stimulus controller. - Next, we do a
POST
request to a/todos/checked
route (which we'll define after this), while appropriately taking care of the cross-origin request (CORS) needs and the request headers. - Then we show an alert with a message saying "Completed"
Now, let's take care of the route we just talked about and the relevant controller action:
Then the todos controller:
Restart your server and test out your new Stimulus powered app.
6. 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!
ActionCable 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!
7. Use 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.
7a. Caching Views
The load time for 2000 records without caching, is 3500ms!
Let's cache everything in "home.html.erb". It's simple,
Next, install the Dalli gem and change the cache store in "development.rb" to:
Then, if you're on Mac or Linux, simply start the Memcached service like this:
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.
7b. 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
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.
7c. Database Indexes
You can also speed up queries by using indexes. A simple query to fetch all addresses of a person,
person.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. 🚀