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