Cache An Expensive Scope In Rails With Automatic Cache Busting On Update
A dashboard query that aggregates across three tables takes 800ms and the underlying data changes a few times an hour. Caching it is obvious; the hard part is invalidating it without a scheduled sweep or a stale-data bug.
Description
Build the cache key from something that changes when the data changes — the collection’s maximum(:updated_at) plus its count. Any insert, update, or delete moves one of those, so the key changes and the cache misses naturally. No manual expiry needed.
cache_key_with_version on a relation does exactly this and is the idiomatic form.
Cache the computed result, not the relation — a relation is lazy and caching one stores an unexecuted query object.
Watch for the thundering herd: when a hot key expires, every request recomputes at once. race_condition_ttl lets one request rebuild while the others serve the stale value.
Sample input:
Order.revenue_summary_for(account)
Sample Output:
# First call: 800ms, computed
# Later calls: <1ms, cached until any order changes
Answer
class Order < ApplicationRecord
scope :expensive_summary, -> {
completed
.joins(:line_items)
.group(:currency)
.select("currency, COUNT(DISTINCT orders.id) AS n, SUM(line_items.subtotal_cents) AS total")
}
def self.revenue_summary_for(account)
scope = where(account_id: account.id)
Rails.cache.fetch(
["revenue-summary", account.id, scope.cache_key_with_version],
expires_in: 1.hour,
race_condition_ttl: 10.seconds
) do
scope.expensive_summary.to_a.map(&:attributes)
end
end
end
# cache_key_with_version derives from count + max(updated_at):
# "orders/query-a3f8c2-count-1420-20260712140233"
# Any insert/update/delete changes it, so the cache busts itself.
# Hand-rolled equivalent, when you need control over the inputs
def self.summary_cache_key(account)
scope = where(account_id: account.id)
[
"revenue-summary",
account.id,
scope.maximum(:updated_at)&.to_i,
scope.count
].join("/")
end
# NOTE: deletes change count, updates change max(updated_at).
# A hard delete plus an insert in the same second is the one gap —
# add a counter or a touch on the parent if that matters.
# Cache the computed value, never the relation
Rails.cache.fetch(key) { scope.to_a } # correct
Rails.cache.fetch(key) { scope } # wrong — lazy, nothing ran
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀