Benito Serna Tips and tools for Ruby on Rails developers

Fix n+1 queries by caching computed values

July 19, 2023

N+1 queries are not always a problem, but I have seen that most of the n+1 queries that are really a problem are when we need to fetch data to compute something.

Here I will try to share some examples of posible expensive computations candidates to be cached and some patterns that you could use to save different kind of values.

Some examples of possible expensive computations

I think the most common computation in many apps will be a count. It is that common that rails already have “counter caches”.

But sometimes you will need to save counts where a counter cache won’t be enough, like:

Or maybe other examples where you need other type of aggregation like:

Should you always cache computations?

No, not really, you should evaluate the pros and cons in each situation.

Sometimes you will need a way to cache the value since the first implementation, but other times it will be better to wait and learn a little more about the problem and cache the value until is really necessary.

Sometimes pre-compute the value will be very easy, but sometimes could be more expensive or very hard, or maybe just not needed.

Patterns to save computed values

There are many ways to save a value, but here I will show you three patterns that I use a lot.

Using a before_save callback

You could use it for calculations that involve information on the same record, like a profile completeness calculation.

class Profile < ApplicationRecord
  before_save :set_completeness_percentage

  def set_completeness_percentage
    self.completeness_percentage = calculate_completeness_percentage
  end
end

When you use a before_save callback, you don’t need to save the record, you just need to change the value and the record will be saved including the fields you modified.

Using after_touch callback

You could use it when you need to save or change a value for things that happen in other records, like updating the balance when an entry is created or updated:

class Account < ApplicationRecord
  has_many :entries

  after_touch :update_balance

  def update_balance
    update(balance: entries.sum(:amount))
  end
end

class Entry < ApplicationRecord
  belongs_to :account, touch: true
end

Using after_commit + background job

Use it for calculations are expensive to run on the same process and is better to run them asynchronously.

class Profile < ApplicationRecord
  after_commit :set_completeness_percentage_later

  def set_completeness_percentage_later
    Profile::SetCompletenessPercentageProfileJob.perform_later(self)
  end

  def set_completeness_percentage
    self.completeness_percentage = calculate_completeness_percentage
  end
end

class Profile::SetCompletenessPercentageProfileJob < ApplicationJob
  def perform(profile)
    profile.set_completeness_percentage
    profile.save!
  end
end

Be aware of race conditions

When saving computed values in the database in your rails app, you must be aware that is possible to find unexpected errors in the result thanks to race conditions.

I have other article that can help you visualize how race conditions can make you save incorrect values even when your calculation is correct.

You can read it on: Saving incorrect computed values thanks to race conditions

Related articles

No more… “Why active record is ignoring my includes?”

Get for free the first part of the ebook Fix n+1 queries on Rails that will help you:

  • Explain what is an n+1 queries problem
  • Identify when ActiveRecord will execute a query
  • Solve the latest comment example
  • Detect n+1 queries by watching the logs
  • Learn the tools to detect n+1 queries
Get the first part of the ebook for free