Speed Up Your Rails App With Memoization

Look I get it, everyone says "Rails is slow" and you want to prove them wrong.

Beyond just the user experience, there are a number of reasons to speed up your website.

Well here's how you can, using a strategy called memoization.

What is Memoization?

Memoization is a technique that involves caching the output of an expensive or slow function so that this function does not have to repeat work the next time it is called.

If you have to query the database, make an API call, or perform any calculation that shouldn't change, it can be way more efficient to memoize the response and cache it for future calls.

AWS Made Easy
AWS Made Easy
How to learn AWS quickly and easily.
AWS Made Easy

As a result, you save your application from having to wait for an unnecessary operation and benefit from a simple yet very effective performance improvement.

Using Memoization in a Ruby on Rails Application

Ruby has a unique operator, ||=, that gives us an immediate and simple version of memoization.

The operation x ||= y evaluates to x || (x = y).

If x evaluates to any truthy value, it is returned. Otherwise, we set x = y, and in most cases y will be the expensive query, API call, or calculation that we are trying to avoid running multiple times.

Here's a real example:

@user ||= User.find(params[:id])

If @user is defined and has a value already it will return that value, otherwise, it will proceed to query the database and call User.find(params[:id]).

The best part, now that this is saved to the @user instance variable, subsequent calls won't trigger additional database queries since that's considered a truth value in our x || x = y arithmetic above.

Multiline Memoization in Ruby

We can support more complex scenarios too by wrapping multiple lines of code in the begin and end keywords.

@phone_number ||= begin
    potential_phone = home_phone if prefers_home_phone?
    potential_phone = work_phone unless potential_phone
    potential_phone = phone_numbers.first unless potential_phone

There is no difference in how the ||= operator processes our @phone_number. If it has already been evaluated we will not call the begin block again.

How to Memoize Null Values

The above solutions are quick and easy but have one major flaw, they don't support nil values.

If the value we are attempting to memoize can evaluate to nil, we need to add some additional logic to be able to memoize this value properly.

A Quick Note on .find vs .find_by

There's a subtle difference between these 2 methods in that only find raises the ActiveRecord::RecordNotFound exception if it can't find the record. If we instead use find_by the result can be nil so we need to make sure we don't repeat this query.

If we attempt to memoize the result of a find_by and the record isn't found, @user will evaluate to nil which will cause our x || x = y logic to retry the query every time we reference @user.

# This does not properly memoize nil values
@user ||= User.find_by(email: params[:email])

To address this we have to differentiate between nil and undefined values.

One way to do this is to call a function that checks if our memoized variable has been defined yet. Even if the value is nil, if we've set it to nil it will still be defined.

# Properly memoizing nil values
def get_user
    return @user if defined? @user
    @user = User.find_by(email: params[:email])

How to Memoize a Method With Multiple Parameters

Things get a little more complex with methods that give us different outputs based on the supplied arguments.

We can't just memoize the result of the method call with the first set of arguments. We need to be able to tell if the same arguments have been called before and only return the result of the call with those exact arguments, otherwise, call the method to get the result of the new arguments.

Let's define a function that has one parameter that is passed along to a SQL query.

def blog_posts(tag)
    BlogPost.where(tag: tag)

You can probably tell that this would be prone to errors if we memoized this method naively.

@posts ||= blog_posts(current_tag)

If we changed the value of current_tag between calls to our blog_posts method, it would return the memoized value of the first call. Despite us passing along different arguments, it wouldn't call the method again.

Luckily we can fix this with Ruby's built-in hash initializer.

The solution looks like this:

def blog_posts(tag)
    @blog_posts ||= Hash.new do |hash, key|
        hash[key] = where(tag: key)

Now we can call blog_posts(current_tag) and each call will return the result of the query given a specific set of arguments. If we haven't run the query with a certain argument it will run and store the result of the query in the hash to prevent future runs of the same query.

Level up faster
Hey, I'm Nick Dill.

I help people become better software developers with daily tips, tricks, and advice.
Related Articles
More like this
How to Export to CSV with Ruby on Rails
Adding Active Storage to your Rails Project
What is MVC and Why Should I Use It?