ruby 81 lines · 3 tabs

Cache Stampede Protection with race_condition_ttl

Shared by codesnips Jan 2026
3 tabs
class LeaderboardCache
  TOP_KEY = "leaderboard:top".freeze
  STATS_KEY = "leaderboard:stats".freeze

  def top_players
    Rails.cache.fetch(TOP_KEY, expires_in: 5.minutes, race_condition_ttl: 15.seconds) do
      Player.order(score: :desc).limit(50).to_a
    end
  end

  def expensive_stats
    cached = Rails.cache.read(STATS_KEY)
    return cached if cached

    lock = RedisLock.new("lock:#{STATS_KEY}", ttl: 30.seconds)

    if lock.acquire
      begin
        value = compute_stats
        Rails.cache.write(STATS_KEY, value, expires_in: 10.minutes)
        value
      ensure
        lock.release
      end
    else
      lock.wait_for_result { Rails.cache.read(STATS_KEY) } || compute_stats
    end
  end

  private

  def compute_stats
    {
      total: Player.count,
      average: Player.average(:score).to_f.round(2),
      generated_at: Time.current
    }
  end
end
3 files · ruby Explain with highlit

A cache stampede (or "dogpile") happens when a popular cache entry expires and dozens of concurrent requests all miss at once, each racing to recompute the same expensive value against the database. This snippet shows two complementary defenses in a Rails app: the built-in race_condition_ttl option and an explicit distributed lock for the truly expensive cases.

The LeaderboardCache service wraps Rails.cache.fetch with expires_in and race_condition_ttl. The trick behind race_condition_ttl is that ActiveSupport stores the value with a slightly longer physical lifetime than the logical expires_in. When the logical entry expires, the first reader that misses immediately re-writes the stale value with a short future expiry (the race_condition_ttl window) so other concurrent readers keep getting the stale-but-valid value while one process recomputes. This bounds the number of simultaneous recomputes to roughly one, at the cost of briefly serving slightly stale data — an acceptable trade for a leaderboard.

race_condition_ttl only helps for entries that already exist and expire; it does nothing for a cold cache or a brand-new key, where every process misses simultaneously. For those genuinely expensive computations, expensive_stats in the same service reaches for with_lock, backed by the RedisLock helper in the second tab. RedisLock#acquire uses SET key value NX PX ttl — an atomic "set if not exists" with an expiry — so exactly one process wins the lock. Losers call wait_for_result, polling the cache briefly rather than piling onto the database. The lock is released with a Lua compare-and-delete so a process never deletes a lock it no longer owns (important if computation overran the TTL).

Notice the deliberate ordering in expensive_stats: check cache, try the lock, recompute only if the lock is won, and always re-check the cache after waiting. The ensure block guarantees the lock is freed even on error. The StatsController ties it together, simply calling the service and rendering — all stampede handling lives behind the cache layer. The pitfalls worth remembering: pick a race_condition_ttl comfortably larger than the recompute time, keep lock TTLs above worst-case compute duration, and never trust a lock you can't prove you still hold.


Related snips

Share this code

Here's the card — post it anywhere.

Cache Stampede Protection with race_condition_ttl — share card
Link copied