NYC
skills/dchuk/rails_ai_agents/caching-strategies

caching-strategies

SKILL.md

Caching Strategies for Rails 8

Overview

Rails provides multiple caching layers:

  • HTTP caching: ETags and fresh_when for 304 Not Modified
  • Fragment caching: Cache view partials
  • Russian doll caching: Nested cache fragments with touch: true
  • Low-level caching: Cache arbitrary data with Rails.cache.fetch
  • Collection caching: Efficient cached rendering of collections
  • Solid Cache: Database-backed caching (Rails 8 default, no Redis)

Cache Store Options

Store Use Case
:memory_store Development
:solid_cache_store Production (Rails 8 default)
:redis_cache_store Production (if Redis available)
:null_store Testing
# config/environments/production.rb
config.cache_store = :solid_cache_store

# config/environments/development.rb
config.cache_store = :memory_store

Enable caching in development:

bin/rails dev:cache

HTTP Caching (ETags / fresh_when)

Use conditional GET to send 304 Not Modified when content has not changed.

class EventsController < ApplicationController
  def show
    @event = current_account.events.find(params[:id])
    fresh_when @event
  end

  def index
    @events = current_account.events.recent
    fresh_when @events
  end
end

Composite ETags

def show
  @event = current_account.events.find(params[:id])
  fresh_when [@event, Current.user]
end

With stale? for JSON

class Api::EventsController < Api::BaseController
  def show
    @event = current_account.events.find(params[:id])
    if stale?(@event)
      render json: @event
    end
  end
end

Fragment Caching

<%# 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>
  </article>
<% end %>

Custom Cache Keys

<% cache [event, "v2"] do %>
  ...
<% end %>

<% cache [event, current_user] do %>
  ...
<% end %>

Russian Doll Caching

Nested caches with automatic invalidation through touch: true:

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :event, touch: true
end
<% cache @event do %>
  <h1><%= @event.name %></h1>
  <% @event.comments.each do |comment| %>
    <% cache comment do %>
      <%= render comment %>
    <% end %>
  <% end %>
<% end %>

When a comment is updated, touch: true cascades up through updated_at timestamps, invalidating all parent caches automatically.

Collection Caching

<%# Caches each item individually, multi-read from cache store %>
<%= render partial: "events/event", collection: @events, cached: true %>

Low-Level Caching

Rails.cache.fetch("stats/#{Date.current}", expires_in: 1.hour) do
  { total_events: Event.count, total_revenue: Order.sum(:total_cents) }
end

In Models

class Board < ApplicationRecord
  def statistics
    Rails.cache.fetch([self, "statistics"], expires_in: 1.hour) do
      {
        total_cards: cards.count,
        completed_cards: cards.joins(:closure).count,
        total_comments: cards.joins(:comments).count
      }
    end
  end
end

With Race Condition Protection

Rails.cache.fetch([self, "stats"], expires_in: 1.hour, race_condition_ttl: 10.seconds) do
  expensive_operation
end

Cache Invalidation

Key-Based (Automatic)

Cache keys include updated_at, so updates automatically expire old entries.

Touch Cascade

class Card < ApplicationRecord
  belongs_to :board, touch: true  # Updates board.updated_at
end

class Comment < ApplicationRecord
  belongs_to :card, touch: true   # Updates card.updated_at -> board.updated_at
end

Manual Invalidation

class Event < ApplicationRecord
  after_commit :invalidate_caches

  private

  def invalidate_caches
    Rails.cache.delete([self, "statistics"])
    Rails.cache.delete("featured_events")
  end
end

Sweeper Pattern

class CacheSweeper
  def self.clear_board_caches(board)
    Rails.cache.delete([board, "statistics"])
    Rails.cache.delete([board, "card_distribution"])
  end
end

Counter Caching

# Migration
add_column :events, :vendors_count, :integer, default: 0, null: false

# Model
class Vendor < ApplicationRecord
  belongs_to :event, counter_cache: true
end

# Usage (no query needed)
event.vendors_count

Cache Warming

class CacheWarmerJob < ApplicationJob
  queue_as :low

  def perform(account)
    account.boards.find_each do |board|
      board.statistics
      board.card_distribution
    end
  end
end

Testing Caching

# test/test_helper.rb (enable caching for specific tests)
class ActiveSupport::TestCase
  def with_caching(&block)
    caching = ActionController::Base.perform_caching
    ActionController::Base.perform_caching = true
    Rails.cache.clear
    yield
  ensure
    ActionController::Base.perform_caching = caching
  end
end

Testing Touch Cascade

# test/models/card_test.rb
require "test_helper"

class CardCachingTest < ActiveSupport::TestCase
  test "touching card updates board updated_at" do
    board = boards(:one)
    card = cards(:one)

    assert_changes -> { board.reload.updated_at } do
      card.touch
    end
  end
end

Testing HTTP Caching

# test/controllers/boards_controller_test.rb
require "test_helper"

class BoardsControllerCachingTest < ActionDispatch::IntegrationTest
  setup do
    sign_in users(:one)
    @board = boards(:one)
  end

  test "returns 304 when board unchanged" do
    get board_url(@board)
    assert_response :success
    etag = response.headers["ETag"]

    get board_url(@board), headers: { "If-None-Match" => etag }
    assert_response :not_modified
  end

  test "returns 200 when board updated" do
    get board_url(@board)
    etag = response.headers["ETag"]

    @board.touch

    get board_url(@board), headers: { "If-None-Match" => etag }
    assert_response :success
  end
end

Testing Cache Invalidation

# test/models/board_test.rb
require "test_helper"

class BoardCacheInvalidationTest < ActiveSupport::TestCase
  test "statistics cache is cleared after card update" do
    board = boards(:one)
    card = cards(:one)

    board.statistics # Warm cache

    card.update!(title: "New title")

    assert_nil Rails.cache.read([board, "statistics"])
  end
end

Memoization

class EventPresenter < BasePresenter
  def vendor_count
    @vendor_count ||= event.vendors.count
  end
end

Checklist

  • Cache store configured for environment
  • fresh_when on show/index actions
  • touch: true on belongs_to for Russian doll
  • Collection caching with cached: true
  • Low-level caching for expensive queries
  • Cache invalidation strategy defined
  • Counter caches for counts
  • Cache warming jobs for cold starts
  • All tests GREEN
Weekly Installs
2
First Seen
7 days ago
Installed on
gemini-cli2
opencode2
antigravity2
codex2
windsurf2
kiro-cli2