Rails Performance Playbook

Optimizing performance on a Ruby on Rails page requires a mix of techniques that tackle both the backend (controller and model) and the frontend (view). This playbook serves as my personal living checklist of things to remember and verify when tackling performance issues in Rails applications I build and maintain. Let's break down the optimization steps:

Backend Optimization

N+1 Queries

The most common low-hanging fruit performance fix for a Ruby on Rails application is to solve for N+1 queries. It's very easy to write a Ruby on Rails view that iterates over a collection and calls a method on each object in the collection that could result in a database query. That means we'll have 1 query to fetch the collection, e.g.:

SELECT 
  * 
FROM 
  projects;

Then 1 query per object in the collection (this is where the N comes from because we'll execute potentially N of these):

SELECT 
  * 
FROM 
  users 
WHERE
  id = ? -- project.manager_id

Rails offers a few tools for eager loading. This article breaks down several details of the options.

I try to always slap a .includes on the query in the controller, which helps most of the time. This will load the project and also run a query to fetch all of the related locations and their addresses. It'll also fetch the manager, their avatar attachment and avatar blobs:

projects = Project.includes(
  location: [:address],
  manager: {avatar_attachment: :blob}
)

There are a couple tools for finding N+1 queries, but none of them are perfect and YMMV. I recommend trying out each of these to try and triangulate a few recommendations for ways to eagerload:

There's a fun article by Justin Weiss about using rack-mini-profiler with flamegraphs here.

I also recommend setting up some basic perf monitoring. We use Sentry and just set up their performance monitoring. You might also want to try something like NewRelic.

Another low-hanging fruit with N+1 queries is related to calling .count on an association. For instance, if we render the number of comments on each project card, it might look like this in the view:

 <div class="mt-4 flex">
  <%= render_svg "icons/chat-bubble-dots" %>
  <%= project.comments.count %>
</div>

Note that project.comments.count will still issue one SQL query per project. Instead, we can use Rails' counter_cache. Again, here's another great full-length article about counter caches.

Counter caches are one type of caching. Rails offers several other caching features.

Caching

When your application data changes slowly, consider caching the results of slow or frequently accessed queries using Rails built-in caching mechanisms. For instance, you might use Rails.cache.fetch with a specific cache key.

Fragment caching in the view can also be useful, especially for parts of the page that don't change often.

The hardest part about using caching features is when to "bust" the cache or let Rails know there's an updated version of the object or view you want to show. It comes down to picking a good cache key that will uniquely identify the latest version of an object and will change if the object changes. For example, you don't want to use the object's ID if the object is going to change. One slightly better option might be the updated_at field, but that, too, might not be enough when working with related objects.

image.png

One easy way to cache parts of views is to use a view partial that contains the bits you expect to cache, use the render method with the partial kwarg, and set the cached arg to true like this:

<% projects.each do |project| %>
  <%= render partial: "projects/card", locals: { project: project }, cached: true %>
<% end %>

Database Indexes

At some point, we'll be hitting the database and likely collecting records and related records. We want to both decrease the number of DB queries and also increase the speed of the queries we run. Adding database indexes is one of many techniques for improving a DB query's performance. You don't want to overdo it and add too many indexes, but ensuring that the columns you query on are indexed is important.

Look at the server logs in local development, specifically to see which columns are used in each WHERE clause. Then look to see if you have an index configured for those columns commonly used in the where clauses. One way to see the indexes is to look for t.index in your db/schema.rb file. Or if you use a gem like annotate, you might also have a comment at the top of the model with the list of indices.

# db/schema.rb
  create_table "projects", force: :cascade do |t|
    t.bigint "location_id", null: false
    t.bigint "manager_id", null: false
    t.integer "project_status", default: 0, null: false
    t.string "title"
    t.date "start_date"
    t.date "end_date"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.datetime "discarded_at"
    t.index ["discarded_at"], name: "index_projects_on_discarded_at"
    t.index ["location_id"], name: "index_projects_on_location_id"
    t.index ["manager_id"], name: "index_projects_on_manager_id"
  end

When you're ready to go super deep on db indexing, I recommend reading through use-the-index-luke.com where Markus Winand provides incredible juicy db details. One small tip: consider composite indexes (more than one column). E.g. if we often query and filter out where discarded is not null and by sales status, we might consider this composite index:

t.index ["discarded_at", "sales_status"], name: "index_projects_on_discarded_at_and_sales_status"

Also, consider partial indexes and sort orders.

Limiting Records

If the number of records can get large, consider adding pagination or infinite scrolling or pagination to load a limited number of projects at once. The easy way to speed up loading a lot of data is not to load a lot of data 😅.

Frontend Optimization

Reduce DOM Elements

Too many DOM elements can slow down a page. I once worked on a very complicated Calendar for vacation rental managers. The calendar used Angular.js and rendered 100k+ elements, bringing UX to unbearable. Consider displaying fewer items or adding pagination.

Lazy Loading

If there are many images (like avatars), consider using lazy loading so they're only loaded when they're about to be displayed on the screen.

Check to see if your application has this enabled:

Rails.application.config.action_view.image_loading = "lazy"

Which will add the loading="lazy" prop to image tags.

JavaScript:

Minimize the amount of JavaScript executed on page load. If there's JavaScript associated with this page, ensure it's optimized and doesn't block the rendering.

CSS:

Make sure your CSS is optimized. Avoid very deep nesting or using universal selectors, which can slow down rendering.

Turbo Streams

Ensure you're not frequently sending large amounts of data over the wire.

Data structures and algorithms

When push comes to shove, it might come down to some data structure or algorithm choices. Remember that using Hash and Set will give you constant time lookup. Consider using those instead of searching through an Array. Also, try to push as much sorting as possible down to the db instead of sorting in memory with Ruby or sorting on the client with JavaScript.


What did I miss? Have other tips and tricks for improving performance? Let me know: wave@cjav.dev