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.
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
end
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])
end
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)
end
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)
end
@blog_posts[tag]
end
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.