Optimize Your Rails Project with Memoization and “||=”

Memoization is useful when the same Rails object calls an expensive method more than once, and the answer will not change between those calls. Instead of repeating a calculation, database query, or API request, we calculate it once and store the result on the object.
The familiar Ruby pattern is only one line:
def overdue_total
@overdue_total ||= @account.invoices.overdue.sum(:amount)
endThe first call performs the query. Later calls on the same object return the stored value. In a controller, presenter, or request-scoped service, that can remove duplicated work with very little code.
The pattern has limits that matter in real Rails applications. ||= recalculates when the result is false or nil; memoized values do not normally survive another request; and a stored database result can become stale. This guide shows the correct pattern for each case rather than treating ||= as a universal cache.
- Use
@value ||= expensive_operation for a method with no arguments when every valid result is truthy.- Use a
defined? guard if false or nil is a valid result.- Use a hash keyed by the arguments when different inputs produce different outputs.
- A memoized instance variable belongs to one object. In ordinary request-scoped Rails code, it normally disappears when that object is discarded.
- Use
Rails.cache.fetch when a value must be reused across requests or processes, shared between application instances, or expired after a defined period.- Measure first. A better SQL query or index may help more than memoization.
How Memoization Removes Repeated Work in Rails
Suppose an account dashboard uses the same overdue invoice IDs to render a count, a warning, and a link. Without memoization, every call to overdue_invoice_ids builds and executes the query again:
class AccountDashboard
def initialize(account)
@account = account
end
def overdue_invoice_ids
@account
.invoices
.overdue
.order(due_on: :asc)
.pluck(:id)
end
endIf the view calls overdue_invoice_ids three times on one AccountDashboard instance, the Rails log may show the query three times. Memoizing the executed result changes that:
class AccountDashboard
def initialize(account)
@account = account
end
def overdue_invoice_ids
@overdue_invoice_ids ||= @account
.invoices
.overdue
.order(due_on: :asc)
.pluck(:id)
end
endOn the first call, pluck(:id) executes SQL and assigns the resulting array to @overdue_invoice_ids. On the second and third calls, Ruby returns that array without evaluating the query chain again.
Why store the result of pluck rather than the relation itself? Active Record relations are lazy. Memoizing an unexecuted relation does not necessarily memoize the database result. pluck(:id) makes the query execution and the stored value explicit.
There is one assumption: the dashboard object is short-lived and the invoice data should remain stable while it is used. If an invoice changes after the first call, this instance still returns the old IDs.
That trade-off is the heart of memoization: we stop repeating work by agreeing to reuse one result for the lifetime of this object.
What ||= Actually Does
Ruby's ||= operator is commonly read as “or equals.” For memoization, we can understand this:
@result ||= expensive_operationapproximately as:
@result || (@result = expensive_operation)On the first call, @result reads as nil, so Ruby evaluates the right-hand side and assigns the result. On later calls, a truthy @result causes the logical OR to short-circuit, and Ruby skips the expensive operation.
This works with 0, empty strings, and empty arrays because they are truthy in Ruby. Only false and nil are falsy.
Before reaching for ||=, ask one question: Can this method legitimately return false or nil? If the answer is yes, use a different guard.
The Trap Most Examples Skip: false and nil
Consider a feature check:
def beta_enabled?
@beta_enabled ||= expensive_feature_check
endIf expensive_feature_check returns false, Ruby stores false. But on the next call, ||= sees a falsy value and performs the check again. The code looks memoized while quietly repeating the expensive work.
Use defined? to distinguish “not assigned yet” from “assigned to a falsy value”:
def beta_enabled?
return @beta_enabled if defined?(@beta_enabled)
@beta_enabled = expensive_feature_check
endNow both false and nil are valid cached results.
Let’s Build Scalable Apps with Ruby on Rails!
We build robust Ruby on Rails applications that scale smoothly and deliver high performance from day one.
A useful review habit is to pause whenever a memoized method ends in ?. Predicate methods commonly return false, which makes ||= the wrong pattern unless the expensive operation is expected to run again after a false result.
What Changes When a Method Accepts Arguments?
One instance variable can hold only one answer. That makes this implementation incorrect:
def shipping_cost(country)
@shipping_cost ||= calculate_shipping(country)
endIf the first call uses "IN", a later call with "US" receives the same stored value. We need a cache entry for each meaningful input:
class ShippingCalculator
def initialize
@shipping_costs = {}
end
def shipping_cost(country)
key = country.to_s.upcase
return @shipping_costs[key] if @shipping_costs.key?(key)
@shipping_costs[key] = calculate_shipping(key)
end
private
def calculate_shipping(country)
# Expensive rate calculation or API request
end
endHash#key? is deliberate. If a calculated cost can be nil, checking only @shipping_costs[key] recreates the same falsy-value bug.
The key must include every input that changes the result. It should also normalize equivalent inputs so "in", "IN", and :IN do not occupy three entries unnecessarily.
Be careful with an open-ended key space. A long-lived object that memoizes thousands of distinct arguments can trade a speed problem for a memory problem. In that case, use a bounded cache with eviction or reconsider the design.
What Memoization Does, and Does Not, Provide
Instance-variable memoization gives us:
- reuse inside one Ruby object;
- no external service or serialization;
- fast access after the first calculation;
- a cache that disappears naturally when the object is discarded.
It does not give us:
- reuse across separate object instances;
- reuse across Rails processes or servers;
- automatic expiration or time-to-live;
- automatic invalidation when a record changes;
- distributed cache consistency.
That boundary explains why memoization can be excellent in a request-scoped presenter or service object but disappointing when we expect the next web request to benefit.
Rails commonly creates new controllers and other request objects for each request. A memoized controller method can remove repeat work within that request. The following request normally starts with a new object and an empty instance variable.
Memoization or Rails.cache? Choose by Lifetime
The important difference is not syntax. It is how long and how widely the result must live.
| Memoization | Rails.cache |
| Stored on one Ruby object | Stored through the configured cache store |
| Usually ends with the object | Can persist across requests |
| Not shared between object instances | Can be shared through Redis, Memcached, or another store |
| No built-in expiration | Supports expiration and cache options |
| Best inside one operation | Best for reusable application data |
If several requests or application processes should reuse a value, Rails.cache.fetch is usually the better fit:
class Product < ApplicationRecord
def competitor_price
Rails.cache.fetch(
[cache_key_with_version, "competitor_price"],
expires_in: 30.minutes
) do
CompetitorClient.fetch_price(external_id)
end
end
endcache_key_with_version changes when the product changes, and expires_in limits how long an external price can remain stale.
For a shared cache, prefer IDs, numbers, strings, arrays, hashes, or other stable serializable values. Caching a live Active Record object can leave the application holding outdated attributes or a record that has since been deleted.
When Memoization Earns Its Place
Memoization is a strong candidate when all of these are true:
- The operation is measurably expensive.
- The same object calls it more than once.
- The result remains valid for that object's lifetime.
- The cached value has a bounded memory cost.
- The method has no side effect that must happen on every call.
Good Rails examples include:
- an aggregate query used several times by one presenter;
- parsing the same configuration document during one job;
- building an expensive lookup table used by several methods;
- calculating a derived value from immutable inputs;
- fetching a stable external value repeatedly during one operation.
Notice that “expensive” and “repeated” both matter. Memoizing a cheap method called once adds state and invalidation concerns without saving useful work.
When It Quietly Backfires
The most common failure is stale data. If a memoized result depends on a record that changes while the object stays alive, the object does not know it should recalculate.
For a short-lived service, creating a new instance is often the clearest reset. If the object must remain alive, make invalidation explicit:
class AccountDashboard
def refresh!
if defined?(@overdue_invoice_ids)
remove_instance_variable(:@overdue_invoice_ids)
end
self
end
endOther production edges deserve attention:
- Side effects: Memoizing a method that sends an event, writes a record, or records a metric may prevent required work after the first call.
- Threads:
||=is not a lock. Two threads using the same object may both perform the expensive calculation. - Scope leakage: Class variables and other globally shared memoization can leak user- or tenant-specific data between requests. Keep request-specific values on request-scoped objects.
- Memory: An argument-keyed hash on a long-lived object can grow without a bound.
- Frozen objects: Assigning a memoization instance variable after an object is frozen raises an error. Calculate before freezing or store the cache elsewhere.
- Lazy relations: Memoizing an unloaded Active Record relation can obscure when SQL actually runs. Store an executed result such as
pluck,load, orto_awhen that matches the intended behavior.
A quick reality check: Rails also has an SQL query cache that may reuse identical SELECT results within its configured scope. Memoization can still avoid rebuilding relations and processing results, but inspect the logs or query counts before assuming it removed database work.
Let’s Build Scalable Apps with Ruby on Rails!
We build robust Ruby on Rails applications that scale smoothly and deliver high performance from day one.
Test the Behavior, Not the One-Line Syntax
A memoization test should verify the answer and confirm that the expensive dependency runs only once:
require "test_helper"
class FeatureAccess
def initialize(checker:)
@checker = checker
end
def enabled?
return @enabled if defined?(@enabled)
@enabled = @checker.call
end
end
class FeatureAccessTest < ActiveSupport::TestCase
test "memoizes a false result" do
checker = Minitest::Mock.new
checker.expect(:call, false)
access = FeatureAccess.new(checker: checker)
assert_equal false, access.enabled?
assert_equal false, access.enabled?
checker.verify
end
endThe mock expects exactly one call. If enabled? used ||=, the second method call would invoke the checker again and the test would fail. This test protects the performance behavior, not just the returned value.
For database-backed methods, add a query-count assertion where appropriate or inspect the Rails log in an integration test. The goal is to prove that repeated work was actually removed.
A Practical Review Checklist
Before merging memoization into a Rails codebase, check:
- Is the method expensive enough to justify extra state?
- Will the same object call it more than once?
- Can the result be
falseornil? - Do method arguments form part of the cache key?
- Can the underlying record or configuration change?
- Is the object's lifetime short and predictable?
- Could the cached value or key set grow too large?
- Is the object shared between threads?
- Should the result instead survive across requests?
- Did a measurement confirm fewer queries, allocations, or milliseconds?
For database-heavy code, also inspect the query itself. An appropriate index, a smaller selected column set, eager loading, or a better SQL plan may deliver a larger and safer improvement than memoization.
Frequently Asked Questions
What is memoization in Ruby?
Memoization stores a method's calculated result so the same object can reuse it on later calls instead of repeating an expensive calculation, query, or request.
How does ||= implement memoization?
||= calculates and assigns the right-hand value when the current variable is nil or false. Once the variable contains a truthy value, Ruby short-circuits and skips the calculation.
Why does ||= fail for false and nil?
Both values are falsy, so ||= evaluates the right-hand expression again. Use defined?(@value) to distinguish an assigned falsy value from an uninitialized instance variable.
How do we memoize a method with arguments?
Store results in a hash keyed by every argument that affects the output. Use Hash#key? if a cached result may be false or nil.
Does Rails memoization persist between requests?
Usually not. Instance-variable memoization belongs to one object, and Rails normally creates new request-scoped objects. Use Rails.cache.fetch when data should be reused across requests or processes.
Should we memoize Active Record queries?
Only when the same object repeats an expensive query and the result remains valid during the object's lifetime. Execute the relation deliberately, keep the object short-lived, and plan for invalidation if its dependencies can change.
Final Thoughts
Memoization is most useful when it stays small and intentional. For a truthy, argument-free result, the familiar Ruby pattern is perfectly clear:
def result
@result ||= expensive_operation
endThe engineering judgment begins around that line. We still need to handle falsy values, include arguments in the cache key, understand the object's lifetime, and decide how stale data will be refreshed.
Use memoization to stop one object from repeating work it has already done. Use Rails.cache when the value belongs beyond that object. Once that boundary is clear, ||= becomes more than a clever shortcut, it becomes a safe, deliberate performance tool.



