Rails Caching Done Right
Caching has a reputation as one of the two hard problems in computer science, and most of that difficulty is self-inflicted. The classic failure mode is imperative invalidation: you cache something, then scatter expire_fragment calls and after_save callbacks across the codebase to clear it whenever the underlying data changes. Six months later a new field is added, nobody updates the eight invalidation sites, and you are serving stale data. The bug is impossible to reproduce locally and infuriating to debug.
Rails has a much better answer, and it has been the recommended one for over a decade: key-based expiration. You do not expire caches. You change the key. A cache entry under an old key is simply never read again and eventually evicted by the store. Get this model into your head and most of caching’s “hard problem” reputation evaporates.
Cache keys: let the data version itself
The foundation is that every Active Record object knows how to describe its own current version. cache_key_with_version produces a string like products/15-20260528120000000000 — the model name, the id, and a version derived from updated_at. The moment the product is touched, updated_at changes, the key changes, and any view or computation keyed on it misses cleanly and recomputes. No invalidation code anywhere.
product.cache_key_with_version
# => "products/15-20260528120000000000"
# A collection keys off its count and the max updated_at, so adding
# or editing any member changes the key for the whole list.
Product.where(store_id: 3).cache_key_with_version
This is also why Rails enables config.active_record.cache_versioning = true by default on modern apps: it splits the stable part of the key (products/15) from the volatile version, so a store like Redis or Memcached can keep one slot per record and overwrite it on update, instead of leaking a new key on every edit. You get correct invalidation and bounded memory.
Russian-doll fragment caching
The signature Rails pattern is nested fragment caches — “Russian dolls.” You cache a list, and inside it you cache each item, with the outer cache keyed so that it naturally includes the inner versions. When one item changes, only its fragment and the outer wrapper are recomputed; every sibling fragment is reused.
<%# index.html.erb %>
<% cache @products %>
<div class="product-grid">
<%= render @products %> <%# renders _product.html.erb per item %>
</div>
<% end %>
<%# _product.html.erb %>
<% cache product %>
<article class="product">
<h3><%= product.name %></h3>
<p><%= number_to_currency(product.price) %></p>
</article>
<% end %>
Two mechanics make this work. The outer cache @products generates a key that folds in the collection’s version (count + max updated_at), so adding or editing any product busts the outer wrapper. And Rails automatically mixes a template digest — an MD5 of the template tree — into every fragment key. Edit the markup in _product.html.erb and the digest changes, so the deploy invalidates every product fragment without a manual cache clear. This is the detail people miss when they hand-roll fragment keys with raw strings and then ship stale markup after a template change.
The one thing you must do for the dolls to nest correctly is touch the parent. If a line item changes and you want the order’s cached fragment to bust, the line item’s belongs_to needs touch: true so saving it bumps the order’s updated_at:
class LineItem < ApplicationRecord
belongs_to :order, touch: true
end
Touch propagation is what makes the whole nested scheme self-maintaining. Without it, the inner fragment updates but the outer one keeps serving the old assembly.
Low-level caching with fetch
Fragment caching is for view output. For everything else — an expensive query result, an external API response, a computed aggregate — there is Rails.cache.fetch, the workhorse of the low-level API. The block runs only on a miss; its return value is stored and returned on subsequent hits.
def monthly_revenue_report(store)
Rails.cache.fetch(["revenue_report", store, Date.current.beginning_of_month],
expires_in: 12.hours) do
ExpensiveRevenueCalculation.new(store).call
end
end
Notice the key is an array. Rails expands each element with its cache_key, so passing store directly means the key carries the store’s version — change the store and the cache busts for free, exactly like the fragment case. This is the right instinct: prefer keys that version themselves over expires_in alone. Use expires_in as a backstop for freshness (the report should not be more than 12 hours stale even if nothing about the store changed), not as your primary correctness mechanism.
Two production flags worth knowing on fetch. race_condition_ttl guards against the thundering herd: when a popular key expires, it briefly lets one process recompute while others serve the slightly-stale value, instead of every process hammering the database simultaneously. And on a cache store that supports it, the same flag combined with a short window dramatically smooths the load spike that a cold popular key would otherwise cause.
Rails.cache.fetch(key, expires_in: 5.minutes, race_condition_ttl: 10.seconds) do
slow_thing
end
The traps that produce stale data
Most caching bugs come from a small set of mistakes:
- Caching at the wrong layer for personalized content. A fragment that includes “Hi, Dana” or a per-user price will leak across users if its key does not include the user. Either key on the user, or pull personalized bits out of the cached fragment and render them outside it.
- Forgetting
touch: trueon associations whose changes should bust a parent fragment. This is the number-one cause of “the child updated but the page still shows the old version.” - Hand-rolling fragment keys as raw strings, which skips the automatic template digest. Then a template edit ships and every cached fragment serves the old markup until something else happens to change the key. Let
cachebuild the key; do not outsmart it. - Caching nil or error results. A
fetchblock that returnsnilon a transient failure will cache theniland serve it for the whole TTL. Decide explicitly whether nil is a real, cacheable answer or a failure you should not store. - Treating the cache as a database. A Redis/Memcached entry can be evicted at any time under memory pressure. Cache is an optimization; code must stay correct when every
fetchmisses.
The takeaway
Caching done right in Rails barely involves invalidation at all. You build keys that version themselves — off updated_at, off collection counts, off the template digest, off the objects you pass to fetch — and let stale entries fall out of the store on their own. The mental shift is from “when does this data change, and where do I clear the cache?” to “what is this cache a function of, and is that in the key?” Get the keys right and the hard problem mostly solves itself; the remaining work is choosing the right layer (fragment vs. low-level) and remembering to touch the parents.
We do caching work on Rails — diagnosing stale-data bugs, designing Russian-doll fragment schemes for slow pages, and sizing Redis/Memcached for the workload. If a page is slow and the data underneath it does not actually change that often, that is usually a fast win worth a conversation.