| name | caching-strategies |
| description | Implements Rails caching patterns for performance optimization. Use when adding fragment caching, Russian doll caching, low-level caching, cache invalidation, or when user mentions caching, performance, cache keys, or memoization. |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
Caching Strategies for Rails 8
Overview
Rails provides multiple caching layers:
- Fragment caching: Cache view partials
- Russian doll caching: Nested cache fragments
- Low-level caching: Cache arbitrary data
- HTTP caching: Browser and CDN caching
- Query caching: Automatic within requests
⚠️ IMPORTANT - Cache Invalidation Philosophy:
This skill shows the standard Rails pattern using after_commit callbacks for cache invalidation.
However, this project's philosophy recommends:
- ❌ NO callbacks for cache invalidation - it's a side effect
- ✅ Invalidate caches explicitly from controllers after successful save
- Helper methods in models, called from controllers
- For multiple side effects (3+), use Event Dispatcher pattern (see
@event_dispatcher_agent)
Choose based on complexity:
- 1-2 side effects: Call explicitly from controller
- 3+ side effects: Use ApplicationEvent.dispatch()
- General Rails pattern: Can use callbacks (shown in examples below)
Quick Start
config.action_controller.perform_caching = true
config.cache_store = :memory_store
config.cache_store = :solid_cache_store
config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }
Enable caching in development:
bin/rails dev:cache
Cache Store Options
| Store | Use Case | Pros | Cons |
|---|
:memory_store | Development | Fast, no setup | Not shared, limited size |
:solid_cache_store | Production (Rails 8) | Database-backed, no Redis | Slightly slower |
:redis_cache_store | Production | Fast, shared | Requires Redis |
:file_store | Simple production | Persistent, no Redis | Slow, not shared |
:null_store | Testing | No caching | N/A |
Fragment Caching
Basic Fragment Cache
<%# app/views/events/_event.html.erb %>
<% cache event do %>
<article class="event-card">
<h3><%= event.name %></h3>
<p><%= event.description %></p>
<time><%= l(event.event_date, format: :long) %></time>
<%= render event.venue %>
</article>
<% end %>
Cache Key Components
Rails generates cache keys from:
- Model name
- Model ID
updated_at timestamp
- Template digest (automatic)
Custom Cache Keys
<%# With version %>
<% cache [event, "v2"] do %>
...
<% end %>
<%# With user-specific content %>
<% cache [event, current_user] do %>
...
<% end %>
<%# With explicit key %>
<% cache "featured-events-#{Date.current}" do %>
<%= render @featured_events %>
<% end %>
Russian Doll Caching
Nested caches where inner caches are reused when outer cache is invalidated:
<%# app/views/events/show.html.erb %>
<% cache @event do %>
<h1><%= @event.name %></h1>
<section class="vendors">
<% @event.vendors.each do |vendor| %>
<% cache vendor do %>
<%= render partial: "vendors/card", locals: { vendor: vendor } %>
<% end %>
<% end %>
</section>
<section class="comments">
<% @event.comments.each do |comment| %>
<% cache comment do %>
<%= render comment %>
<% end %>
<% end %>
</section>
<% end %>
Touch for Cascade Invalidation
class Comment < ApplicationRecord
belongs_to :event, touch: true
end
class EventVendor < ApplicationRecord
belongs_to :event, touch: true
belongs_to :vendor
end
Collection Caching
Efficient Collection Rendering
<%# Caches each item individually %>
<%= render partial: "events/event", collection: @events, cached: true %>
<%# Equivalent to: %>
<% @events.each do |event| %>
<% cache event do %>
<%= render event %>
<% end %>
<% end %>
With Custom Cache Key
<%= render partial: "events/event",
collection: @events,
cached: ->(event) { [event, current_user.admin?] } %>
Low-Level Caching
Basic Read/Write
Rails.cache.fetch("stats/#{Date.current}", expires_in: 1.hour) do
{
total_events: Event.count,
total_revenue: Order.sum(:total_cents)
}
end
stats = Rails.cache.read("stats/#{Date.current}")
Rails.cache.write("stats/#{Date.current}", stats, expires_in: 1.hour)
Rails.cache.delete("stats/#{Date.current}")
In Service Objects
class DashboardStatsService
CACHE_KEY = "dashboard_stats"
CACHE_TTL = 15.minutes
def call(account:)
Rails.cache.fetch(cache_key(account), expires_in: CACHE_TTL) do
calculate_stats(account)
end
end
def invalidate(account:)
Rails.cache.delete(cache_key(account))
end
private
def cache_key(account)
"#{CACHE_KEY}/#{account.id}"
end
def calculate_stats(account)
{
events_count: account.events.count,
upcoming_events: account.events.upcoming.count,
total_revenue: calculate_revenue(account)
}
end
end
In Query Objects
class DashboardStatsQuery
def initialize(account:, use_cache: true)
@account = account
@use_cache = use_cache
end
def upcoming_events(limit: 5)
return fetch_upcoming_events(limit) unless @use_cache
Rails.cache.fetch(cache_key("upcoming", limit), expires_in: 5.minutes) do
fetch_upcoming_events(limit)
end
end
private
def cache_key(type, *args)
"dashboard/#{@account.id}/#{type}/#{args.join('-')}"
end
def fetch_upcoming_events(limit)
@account.events.upcoming.limit(limit).to_a
end
end
Cache Invalidation
Time-Based Expiration
Rails.cache.fetch("key", expires_in: 1.hour) { ... }
Key-Based Expiration
cache_key = "event/#{event.id}-#{event.updated_at.to_i}"
Rails.cache.fetch(cache_key) { ... }
Manual Invalidation
❌ Anti-Pattern: Callback-Based Invalidation
class Event < ApplicationRecord
after_commit :invalidate_caches
private
def invalidate_caches
Rails.cache.delete("featured_events")
Rails.cache.delete_matched("dashboard/#{account_id}/*")
end
end
Problems:
- Cache invalidation is a side effect (not data normalization)
- Hard to test (need to stub cache in model specs)
- Hidden behavior (not visible in controller)
- Violates this project's convention
✅ Correct Pattern: Explicit Invalidation from Controller
class Event < ApplicationRecord
def invalidate_caches
Rails.cache.delete("featured_events")
Rails.cache.delete_matched("dashboard/#{account_id}/*")
end
end
class EventsController < ApplicationController
def update
if @event.update(event_params)
@event.invalidate_caches
redirect_to @event
else
render :edit, status: :unprocessable_entity
end
end
end
✅ Alternative: Service with Invalidation
class Events::UpdateService
include Dry::Monads[:result]
def call(event, params)
event.update!(params)
invalidate_related_caches(event)
Success(event)
rescue ActiveRecord::RecordInvalid => e
Failure(e.record.errors.full_messages.join(", "))
end
private
def invalidate_related_caches(event)
Rails.cache.delete("event_count/#{event.account_id}")
Rails.cache.delete("featured_events")
DashboardStatsService.new.invalidate(account: event.account)
end
end
class EventsController < ApplicationController
def update
result = Events::UpdateService.new.call(@event, event_params)
if result.success?
redirect_to result.value!
else
flash.now[:alert] = result.failure
render :edit, status: :unprocessable_entity
end
end
end
✅ Alternative: Event Dispatcher for Multiple Side Effects
class EventsController < ApplicationController
def update
if @event.update(event_params)
ApplicationEvent.dispatch(:event_updated, @event)
redirect_to @event
else
render :edit, status: :unprocessable_entity
end
end
end
ApplicationEvent.on(:event_updated) { |event| event.invalidate_caches }
ApplicationEvent.on(:event_updated) { |event| event.broadcast_update }
ApplicationEvent.on(:event_updated) { |event| EventMailer.updated(event).deliver_later }
Pattern-Based Deletion
Rails.cache.delete_matched("dashboard/*")
Rails.cache.delete("dashboard/#{account_id}/stats")
Rails.cache.delete("dashboard/#{account_id}/events")
HTTP Caching
Conditional GET (ETag/Last-Modified)
class EventsController < ApplicationController
def show
@event = Event.find(params[:id])
if stale?(@event)
respond_to do |format|
format.html
format.json { render json: @event }
end
end
end
def index
@events = current_account.events.recent
if stale?(etag: @events, last_modified: @events.maximum(:updated_at))
render :index
end
end
end
Cache-Control Headers
class Api::EventsController < Api::BaseController
def show
@event = Event.find(params[:id])
expires_in 1.hour, public: true
expires_in 15.minutes, private: true
render json: @event
end
end
Memoization
Instance Variable Memoization
class EventPresenter < BasePresenter
def vendor_count
@vendor_count ||= event.vendors.count
end
def total_cost
@total_cost ||= calculate_total_cost
end
private
def calculate_total_cost
event.event_vendors.sum(:amount_cents)
end
end
Request-Scoped Memoization
class Current < ActiveSupport::CurrentAttributes
attribute :dashboard_stats
def dashboard_stats
super || self.dashboard_stats = DashboardStatsQuery.new(user: user).call
end
end
Counter Caching
Built-in Counter Cache
add_column :events, :vendors_count, :integer, default: 0, null: false
class Vendor < ApplicationRecord
belongs_to :event, counter_cache: true
end
event.vendors_count
Custom Counter Cache
class Event < ApplicationRecord
after_commit :update_account_counters
private
def update_account_counters
account.update_columns(
events_count: account.events.count,
active_events_count: account.events.active.count
)
end
end
class EventsController < ApplicationController
def create
@event = Event.new(event_params)
authorize @event
if @event.save
update_account_event_counters(@event.account)
redirect_to @event
else
render :new, status: :unprocessable_entity
end
end
private
def update_account_event_counters(account)
account.update_columns(
events_count: account.events.count,
active_events_count: account.events.active.count
)
end
end
Testing Caching
Spec Configuration
RSpec.configure do |config|
config.around(:each, :caching) do |example|
caching = ActionController::Base.perform_caching
ActionController::Base.perform_caching = true
Rails.cache.clear
example.run
ActionController::Base.perform_caching = caching
end
end
Testing Cached Views
RSpec.describe "Events", type: :request, :caching do
it "caches the event show page" do
event = create(:event)
get event_path(event)
expect(response.body).to include(event.name)
event.update!(name: "New Name")
get event_path(event)
expect(response.body).to include("New Name")
end
end
Testing Cache Invalidation
RSpec.describe DashboardStatsService do
describe "#invalidate" do
it "clears the cache" do
account = create(:account)
service = described_class.new
service.call(account: account)
service.invalidate(account: account)
expect(Rails.cache.exist?("dashboard_stats/#{account.id}")).to be false
end
end
end
Performance Monitoring
Cache Hit/Miss Logging
config.action_controller.enable_fragment_cache_logging = true
Custom Instrumentation
ActiveSupport::Notifications.subscribe("cache_read.active_support") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Rails.logger.info "Cache #{event.payload[:hit] ? 'HIT' : 'MISS'}: #{event.payload[:key]}"
end
Checklist