Blogs/Technology

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

Written byPiyush Duragkar
Aug 18, 2026
8 Min Read
Optimize Your Rails Project with Memoization and “||=” Hero

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)
end

The 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.

Too Long? Read This First

- 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
end

If 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
end

On 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_operation

approximately 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
end

If 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
end

Now 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)
end

If 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
end

Hash#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.

MemoizationRails.cache
Stored on one Ruby objectStored through the configured cache store
Usually ends with the objectCan persist across requests
Not shared between object instancesCan be shared through Redis, Memcached, or another store
No built-in expirationSupports expiration and cache options
Best inside one operationBest for reusable application data
Stored on one Ruby object
Rails.cache
Stored through the configured cache store
1 of 5

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
end

cache_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:

  1. The operation is measurably expensive.
  2. The same object calls it more than once.
  3. The result remains valid for that object's lifetime.
  4. The cached value has a bounded memory cost.
  5. 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
end

Other 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, or to_a when 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
end

The 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 false or nil?
  • 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
end

The 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.

Author-Piyush Duragkar
Piyush Duragkar

Backend Developer with 3+years of experience with Ruby on Rails. Specialising in backend optimisation, API development, and database. Enthusiastic about developing scalable, effective solutions.

Share this article

Phone

Next for you

8 Best GraphQL Libraries for Node.js in 2025 Cover

Technology

Aug 4, 202613 min read

8 Best GraphQL Libraries for Node.js in 2025

8 Best GraphQL Libraries for Node.js in 2026 Too Long? Read This First - Choose Apollo Server when you need a mature ecosystem, GraphOS integration, plugins, or Apollo Federation. - Choose GraphQL Yoga for a modern, portable server with Fetch API compatibility and built-in support for subscriptions over Server-Sent Events. - Choose Mercurius when your application already uses Fastify and runtime efficiency is a major priority. - Use GraphQL.js when you need the official JavaScript implementati

9 React Native Animation Libraries and Tools Compared Cover

Technology

Aug 4, 202615 min read

9 React Native Animation Libraries and Tools Compared

Too Long? Read This First - Use React Native Reanimated for gesture-driven, interruptible, and performance-sensitive interface animations. - Use the built-in Animated API for simple fades, transforms, and timed sequences without another dependency. - Pair React Native Gesture Handler with Reanimated for swipes, dragging, pinching, rotation, and other touch-driven experiences. - Use Lottie React Native for non-interactive motion graphics supplied by designers. - Choose React Native Skia for cust

9 Critical Practices for Secure Web Application Development Cover

Technology

Aug 4, 202616 min read

9 Critical Practices for Secure Web Application Development

Too Long? Read This First - Define security requirements and model threats before implementation begins. - Treat authentication, account recovery, and MFA as one complete identity system. - Apply server-side authorization to every protected action and object. - Prevent injection with parameterized APIs, structured validation, safe output handling, and restricted outbound requests. - Protect sessions and tokens throughout their complete lifecycle. - Minimise sensitive data and manage encryption