Rails is Fast: Optimize Your View Performance

Swaathi Kakarla

Swaathi Kakarla on

Last updated:

Rails is Fast: Optimize Your View Performance

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:

# == 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

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

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

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

<ul> <% @people.each do |person| %> <li id="<%= %>"><%= render person %></li> <% end %> </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'

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

<ul> <li> 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) %>

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

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)

2. Load Only What You Will Use

This is how the homepage looks.

Image 10

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>

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 %>

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 %>

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

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.

rails new hotwire_todo_list_app -e esbuild --css=tailwind

Once the app finishes installing, open it up in your favorite editor then create a new resource called Todo in the terminal:

rails g resource Todo title:string complete:boolean

Next, migrate the database to create the todos table:

rails db:migrate

Then open up the todos_controller.rb and modify it like so:

# controllers/todos_controller.rb class TodosController < ApplicationController def index @todos = Todo.all @todo = end def create @todo = if redirect_to root_path, notice: 'Todo created!' else render :index end end private def todo_params params.require(:todo).permit(:description, :completed) end end

And remember to add a corresponding view as well. We'll use something very basic:

# views/todos/index.html.erb <div class="flex flex-col"> <div> <h1 class="text-xl font-bold">Todo List</h1> </div> <div class="mt-3"> <%= render 'form', task: @task %> </div> </div>

Next, we add a form partial we've referenced in the index view for creating Todo's:

# views/todos/_form.html.erb <%= form_with(model: @todo) do |f| %> <%= f.text_field :description, class: 'basic-form-input' %> <%= f.submit 'Save Todo', class: 'btn-primary' %> <% end %>

Also modify routes.rb to create a root route:

# config/routes.rb Rails.application.routes.draw do resources :todos root "todos#index" end

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

# views/index.html.erb ... <div id="todo-list" class="mt-5"> <h3 class="font-semibold text-lg">Todo List:</h3> <% @todos.each do |todo| %> <%= form_with(model: todo) do |f| %> <div class="flex items-center"> <%= f.check_box :completed, class: 'basic-checkbox' %> <p class="basic-checkbox-label"><%= todo.description %></p> </div> <% end %> <% end %> </div> ...

Next, let's add Stimulus. Add a data-controller to the parent div of the elements you want to change, like so:

# views/index.html.erb ... <div id="todo-list" class="mt-5" data-controller="todos"> <h3 class="font-semibold text-lg">Todo List:</h3> <% @todos.each do |todo| %> <%= form_with(model: todo) do |f| %> <div class="flex items-center"> <%= f.check_box :completed, class: 'basic-checkbox' %> <p class="basic-checkbox-label"><%= todo.description %></p> </div> <% end %> <% end %> </div> ...

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:

rails g stimulus todos

Which will generate an app/javascript/controllers/todos_controller.js Stimulus controller and content similar to the code below:

// app/javascript/controllers/todos_controller.js import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="todos" export default class extends Controller { connect() {} }

Before editing this file further, let's first modify the checkbox in the todos list by adding a data attribute as shown below:

# views/index.html.erb ... <div id="todo-list" class="mt-5" data-controller="todos"> <h3 class="font-semibold text-lg">Todo List:</h3> <% @todos.each do |todo| %> <%= form_with(model: todo) do |f| %> <div class="flex items-center"> <%= f.check_box :completed, class: 'basic-checkbox', data: { id:, action: "todos#toggle" } %> <p class="basic-checkbox-label"><%= todo.description %></p> </div> <% end %> <% end %> </div> ...

Next, we need to define the toggle method for the checkbox's data action in the todos Stimulus controller:

// app/javascript/controllers/todos_controller.js ... export default class extends Controller { connect() { } toggle(e) { const id = const csrfToken = document.querySelector("[name='csrf-token']").content fetch(`/todos/${id}/checked`, { method: 'POST', mode: 'cors', cache: 'no-cache', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ completed: }) }) .then(response => response.json()) .then(data => { alert(data.message) }) }

In a nutshell, this is what's going on:

  • First, we pass an event - e - corresponding to the checkbox being checked in the toggle 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:

# config/routes.rb Rails.application.routes.draw do resources :todos root "todos#index" post '/todos/:id/checked', to: 'todos#checked' # Add this route end

Then the todos controller:

# app/controllers/todos_controller.rb ... def checked @todo = Todo.find(params[:id]) @todo.update( completed: params[:completed]) render json: { message: "Todo marked as completed!" } end ...

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,

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

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

config.cache_store = :dalli_store

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

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

Image 8

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,


This query asks the Address table to return all addresses where person_id column is Without indexes, the database has to inspect each row individually to check if it matches However, with indexes, the database has a list of addresses that match a certain

Here's a great resource to learn more about database indexes!


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

Swaathi Kakarla

Swaathi Kakarla

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.

All articles by Swaathi Kakarla

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps