| name | rails-cache-performance |
| description | Rails cache invalidation, stale cache detection, request-level fragment caching, and Russian doll caching patterns. |
| license | MIT |
| metadata | {"version":"1.1.0","hermes":{"tags":["Rails","Caching","Performance","Patterns"],"related_skills":["rails-testing-patterns","activerecord-application-query-optimization"]}} |
Rails Cache Performance
Fragment Cache Invalidation
Use touch cascades so parent records auto-invalidate when children update:
class Image < ApplicationRecord
belongs_to :viewable, polymorphic: true, touch: true
end
Costs to weigh: every child save issues an extra parent UPDATE — on hot parents (many
concurrent child writes) that's row-lock contention and write amplification; bulk child
updates become touch storms. And the cascade is one level: Russian-doll invalidation up a
deeper graph needs touch: true at EVERY intermediate belongs_to, paired with nested
cache blocks in the views keyed on each record.
Multi-Layer Cache Clearing
Clear all layers: Rails.cache.delete(key) (app cache), @cached_value = nil (memoization), reload (association cache).
Stale Cache Detection
Routing caches storing record IDs cause sporadic 404s when cached IDs no longer exist. Use atomic fetch with stampede prevention:
record = Rails.cache.fetch(cache_key,
expires_in: 10.seconds,
race_condition_ttl: 5.seconds) do
find_by(name: lookup_name)
end
Simpler alternative: for indexed lookups (<1ms), shrink TTL from minutes to 10 seconds — zero logic changes; the stale window shrinks proportionally to the TTL cut (e.g. 1-5min → 10s).
For tenant/subdomain resolution specifically, a stale cached ID is not just a 404: if the
name is freed and reassigned within the TTL window, requests can resolve to the WRONG
tenant's record — cross-tenant exposure. That is the concrete reason the security note
below says to consider no cache at all on those paths.
Cache TTL Selection
| TTL | Use Case | Recommendation |
|---|
| 10s | Routing/resolution (subdomain, tenant) | Default for routing |
| 30s | Stable data, higher traffic | Good middle ground |
| 1min | Very stable data | Maximum for routing |
| 5min+ | Static reference data | Avoid for routing |
For security-critical paths (multi-tenant resolution, auth): consider removing cache entirely. ~2ms per-request cost is negligible vs. data breach risk.
Request-Scoped Caching for Current.* Attributes
Check if already set before calling the setter — without early return, each partial/helper call re-evaluates:
def current_campaign
return Current.campaign if Current.campaign.present?
set_current_campaign
end
Nil never latches with this guard: when there legitimately IS no campaign, the setter
re-runs on every call. If absent-is-common, memoize the LOOKED-UP state (a
Current.campaign_resolved flag) rather than the value's presence.
Log Volume Reduction
Enable lograge, optionally suppressing ActionView render notifications (the unsubscribe
lines, not lograge itself, are what removes render lines — a large reduction in render-heavy
apps):
config.lograge.enabled = true
config.lograge.ignore_actions = ['HealthController#index']
ActiveSupport::Notifications.unsubscribe("render_template.action_view")
ActiveSupport::Notifications.unsubscribe("render_partial.action_view")
unsubscribe is global: it removes EVERY subscriber of that event — including APM
instrumentation (New Relic, Skylight, AppSignal, Datadog view-render metrics). If you run
an APM, skip the unsubscribe lines or your view breakdowns go silently blank.