/ Tags: RUBY 3 / Categories: RUBY

Memoization at Depth — Where ||= Works, Where It Quietly Breaks

@value ||= expensive_call is the first optimization most Ruby developers learn and the one they keep using long after it stops being correct. It works beautifully for a non-nil, non-false result computed from no arguments. Step outside those constraints — nil results, boolean results, methods that take parameters, threads — and it fails in ways that don’t raise, don’t log, and don’t show up until someone notices the same query running eight thousand times.

What ||= Actually Expands To


The operator is sugar, and knowing the expansion explains every edge case that follows.

Example:

@user ||= find_user
# expands to
@user || @user = find_user

It’s a truthiness check. If @user is truthy, return it. If it’s nil or false, run the assignment. Nothing about it means “have I computed this yet” — it means “is the current value truthy.”

That distinction is the whole article.

The Nil Result Problem


Example:

class Order
  def preferred_shipping_address
    @preferred_shipping_address ||= customer.addresses.find_by(preferred: true)
  end
end

Customer has no preferred address. find_by returns nil. The memo stores nil. Next call: nil is falsy, so it queries again. And again. This method is memoized for every customer except the ones where the query is a miss — precisely the case where you’d most like to avoid repeating it.

Miss-heavy workloads are common. Feature-flag lookups, optional associations, cache probes, config overrides — all of them return nil most of the time, and all of them are silently unmemoized under ||=.

The defined? fix
def preferred_shipping_address
  return @preferred_shipping_address if defined?(@preferred_shipping_address)

  @preferred_shipping_address = customer.addresses.find_by(preferred: true)
end

defined?(@ivar) asks whether the variable has been assigned, not whether its value is truthy. It returns "instance-variable" or nil, so the guard is precise. This handles nil and false correctly and is the general-purpose form.

The cost is verbosity — three lines where one used to do. That’s the trade, and for a method that’s genuinely hot it’s worth paying.

The sentinel fix
class Order
  UNSET = Object.new.freeze
  private_constant :UNSET

  def preferred_shipping_address
    @preferred_shipping_address = UNSET unless defined?(@preferred_shipping_address)
    return @preferred_shipping_address unless @preferred_shipping_address.equal?(UNSET)

    @preferred_shipping_address = customer.addresses.find_by(preferred: true)
  end
end

More machinery than most methods deserve. Useful when you’re building a memoization helper rather than memoizing one method.

The Boolean Problem


Same root cause, more dangerous because the values look intentional.

Example:

def eligible_for_discount?
  @eligible_for_discount ||= calculate_eligibility   # returns true or false
end

When eligibility is false, every call recomputes. If calculate_eligibility hits the database or an external API, you’ve built a method that’s cheap for eligible customers and expensive for everyone else — an inversion of what you probably want, and one that only shows up under a traffic mix your local testing doesn’t reproduce.

Predicate methods should essentially never use ||=. Use defined?.

Memoizing Methods That Take Arguments


An instance variable holds one value. A method with parameters has one result per argument set. ||= collapses them, which is a correctness bug rather than a performance one.

Example:

# Broken — second call returns the first call's answer
def exchange_rate(currency)
  @exchange_rate ||= FxService.fetch(currency)
end

exchange_rate("USD")   # => 1.0
exchange_rate("EUR")   # => 1.0  ← wrong

Example:

def exchange_rate(currency)
  @exchange_rates ||= {}
  @exchange_rates.fetch(currency) do
    @exchange_rates[currency] = FxService.fetch(currency)
  end
end

fetch with a block is the right primitive here — it distinguishes “key absent” from “value is nil,” which [] cannot. For multiple arguments, key on an array:

def shipping_quote(origin, destination, weight)
  @shipping_quotes ||= {}
  key = [origin, destination, weight]
  @shipping_quotes.fetch(key) { @shipping_quotes[key] = Carrier.quote(*key) }
end

A Hash.new with a block reads more cleanly when the computation is small:

def exchange_rates
  @exchange_rates ||= Hash.new { |cache, currency| cache[currency] = FxService.fetch(currency) }
end

exchange_rates["USD"]

Be careful with that last form — the default block mutates the hash while reading it, which is fine single-threaded and a hazard otherwise.

The Unbounded Growth Problem


Hash-based memoization on a long-lived object is a memory leak with good manners. Every distinct argument adds an entry and nothing ever removes one.

On a per-request object this is harmless — the object dies at the end of the request. On a singleton, a class-level cache, or anything held by a long-running worker, it grows until the process is restarted.

Example:

class RateCache
  MAX_ENTRIES = 500

  def initialize
    @store = {}
  end

  def fetch(currency)
    @store.delete(currency) if @store.key?(currency)   # move to end (LRU)
    @store[currency] = yield(currency) unless @store.key?(currency)
    @store.shift if @store.size > MAX_ENTRIES          # evict oldest
    @store[currency]
  end
end

Ruby hashes preserve insertion order, which gives you a serviceable LRU in a few lines. Once you need TTLs or cross-process sharing, stop hand-rolling and use Rails.cache.

Thread Safety


||= is not atomic. Two threads can both see the memo empty and both run the computation.

Example:

def config
  @config ||= load_config_from_disk
end

Under concurrent access this can read the file twice. For an idempotent, side-effect-free computation that’s a wasted call and nothing more — usually acceptable. When the computation has side effects, registers something, or must yield one shared object identity, it isn’t.

Example:

require "monitor"

class ConfigLoader
  include MonitorMixin

  def config
    return @config if defined?(@config)

    synchronize do
      @config = load_config_from_disk unless defined?(@config)
    end
    @config
  end
end

The double check keeps the fast path lock-free while making the slow path exclusive. Note the second defined? inside the lock — without it, two threads that both got past the first check will both compute.

Pro-Tip: The bug that costs the most time is memoizing across a state change. A method memoized at request start holds a stale value after an update in the same request, and the symptom is a page that shows the old total right after saving. Rails models are especially prone to this because reload clears ActiveRecord’s own caches but knows nothing about your instance variables. If you memoize on a model, define a clear_memoization! that resets every memo ivar and call it from an after_save callback — or better, don’t memoize on objects whose state changes. Memoize on short-lived query objects and presenters instead, where the object is discarded before the underlying data can shift beneath it.

When Not to Memoize at All


Memoization has a real cost: an extra branch, an instance variable, a cache-invalidation question, and a method that’s harder to reason about. It’s worth it when the computation is genuinely expensive and called repeatedly on the same object.

It’s not worth it for:

  • Cheap attribute derivationfull_name concatenating two strings is faster than the hash lookup guarding it
  • Methods called once per object lifetime, which is most of them
  • Values that must reflect current state — anything reading mutable data
  • Anything already cached downstream — ActiveRecord caches associations; memoizing user.posts re-caches a cache

Profile before adding it. benchmark-ips on the real call pattern will tell you in two minutes whether the branch pays for itself.

Conclusion


||= is correct for one narrow case: a zero-argument method returning a value that’s never nil or false, on an object that doesn’t change. That case is common enough to make the idiom look universal, which is why the failures are so persistent. Use defined? when the result can be falsy, a hash keyed on arguments when the method takes parameters, an explicit bound when the object is long-lived, and a monitor when threads are involved. And before any of it, confirm the method is actually hot — an unnecessary memo is a correctness risk bought with no performance in return.

FAQs


Q1: Is @x ||= nil ever useful?
No — it’s a no-op that recomputes forever. If the value can legitimately be nil, you need defined?(@x) or a sentinel object.

Q2: Does defined? have a runtime cost?
It’s resolved cheaply and doesn’t evaluate its argument. The difference against ||= is not measurable in any realistic workload, so correctness should decide which you use.

Q3: Should I use a memoization gem?
Gems like memo_wise handle arguments, thread safety, and cache clearing with a clean API, and they’re a good fit when you have many memoized methods. For two or three, the hand-written form is clearer than a dependency.

Q4: How do I memoize a class-level method?
Same patterns, on self: def self.regions; @regions ||= load_regions; end. Remember that class-level state persists for the process lifetime, so bounds and thread safety matter far more than they do on instances.

Q5: Why does my memoized value go stale after reload?
reload clears ActiveRecord’s association and attribute caches but not your own instance variables. Override reload to reset your memo ivars and call super, or avoid memoizing on records that get reloaded.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More