Introduction
Memoization stores the result of an expensive computation so it is only calculated once. In Ruby, the ||= operator is the idiomatic way to memoize, but there are subtleties around nil/false values, thread safety, and memory growth that every production developer must understand.
Key Concepts
- Memoization: Caching the return value of a method so subsequent calls return the cached result without re-executing the method body.
||=(or-equals): The standard Ruby memoization operator.@value ||= computeassignscomputeonly if@valueisnilorfalse.- Lazy Initialization: Deferring object creation until the object is first needed, reducing startup cost and memory usage.
- LRU Cache: A cache with a maximum size that evicts the Least Recently Used entry when full, preventing unbounded memory growth.
Real World Context
In a Rails application, a method like current_user might hit the database on every call within a request. Memoizing it with @current_user ||= User.find(session[:user_id]) turns N database queries into one. However, memoizing at the class or module level (rather than instance level) can cause memory leaks that persist across requests, silently growing until the process is killed by the OOM killer.
Deep Dive
Basic Memoization with ||=
The simplest and most common pattern:
rubyclass Report def total_revenue @total_revenue ||= orders.sum(&:amount) end end
The first call computes the sum and stores it in @total_revenue. All subsequent calls return the cached value instantly.
The nil/false Trap
||= does not work correctly when the memoized value can legitimately be nil or false:
rubyclass Feature def enabled? # BUG: if the result is false, this re-computes every time @enabled ||= Settings.check(:feature_flag) end end
Since false || Settings.check(...) re-evaluates the right side, the memoization breaks. Use defined? or a sentinel instead:
rubyclass Feature def enabled? return @enabled if defined?(@enabled) @enabled = Settings.check(:feature_flag) end end
Now @enabled is cached regardless of whether it is true, false, or nil.
Hash-Based Computed Caches
For methods that accept arguments, use Hash.new with a block to build a self-populating cache:
rubyclass TaxCalculator def initialize @cache = Hash.new { |hash, region| hash[region] = compute_tax_rate(region) } end def tax_rate(region) @cache[region] # Computed on first access, cached thereafter end private def compute_tax_rate(region) # Expensive API call or database lookup TaxApi.rate_for(region) end end
The block is called automatically for any key not yet in the hash, making the cache self-populating and clean.
LRU Cache for Bounded Memory
Unbounded caches grow forever. In long-running processes (Sidekiq workers, Puma servers), this leads to memory bloat. An LRU cache evicts the oldest entries:
rubyclass LruCache def initialize(max_size: 1000) @max_size = max_size @data = {} end def fetch(key) if @data.key?(key) # Move to end (most recently used) by re-inserting @data[key] = @data.delete(key) else @data[key] = yield @data.delete(@data.first[0]) if @data.size > @max_size end @data[key] end end cache = LruCache.new(max_size: 500) result = cache.fetch("user:42") { User.find(42) }
Ruby's Hash preserves insertion order (since Ruby 1.9), so deleting and re-inserting moves a key to the end. When the cache exceeds max_size, the first (oldest) key is evicted.
When Memoization Hurts
Memoization trades memory for speed. It hurts when:
- The cached value is large and the method is called with many distinct arguments (cache grows without bound).
- The cached value becomes stale (e.g., memoizing a database result across requests in a class variable).
- The method is only called once anyway, so the overhead of the instance variable check is pure waste.
Always ask: "How many distinct values will this cache hold, and for how long?"
Common Pitfalls
- Memoizing
nil/falsewith||=— As shown above,||=re-evaluates when the cached value is falsy. Usedefined?or a sentinel value to handle these cases. - Class-level memoization leaks — Storing memoized values in class variables (
@@cache) or module-level instance variables means the cache persists across requests and grows without bound. Prefer instance-level memoization scoped to the request lifecycle. - Thread-unsafe memoization — In multi-threaded servers (Puma),
||=is not atomic. Two threads can race and both execute the expensive computation. For truly expensive operations, use aMutexor accept the occasional duplicate computation.
Best Practices
- Scope memoization to the instance — Use
@ivar ||=so the cache is garbage-collected when the object is. Avoid@@class_varmemoization in web applications. - Bound your caches — Any cache that accepts arbitrary keys should have a maximum size. Use an LRU strategy or TTL-based expiry to prevent unbounded growth.
- Document cache lifetimes — Add a comment explaining how long the memoized value is valid and what invalidates it. Future maintainers will thank you.
Summary
||=is the standard Ruby memoization pattern but fails fornil/falsevalues.- Use
defined?(@ivar)to memoize values that can be falsy. Hash.new { |h, k| h[k] = compute(k) }creates self-populating caches for parameterized methods.- Unbounded caches cause memory bloat; use LRU eviction in long-running processes.
- Always consider the memory-vs-speed tradeoff before memoizing.
Code Examples
class UserProfile
def initialize(user_id)
@user_id = user_id
end
# Simple memoization — safe when result is never nil/false
def display_name
@display_name ||= fetch_display_name
end
# Safe memoization for nil/false values
def admin?
return @is_admin if defined?(@is_admin)
@is_admin = Permissions.admin?(@user_id)
end
# Parameterized memoization with Hash.new
def permission(scope)
@permissions ||= Hash.new { |h, s| h[s] = Permissions.check(@user_id, s) }
@permissions[scope]
end
private
def fetch_display_name
User.find(@user_id).name
end
end