CtrlK
BlogDocsLog inGet started
Tessl Logo

caching-strategies

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.

Install with Tessl CLI

npx tessl i github:fernandezbaptiste/rails_ai_agents --skill caching-strategies
What are skills?

90

Does it follow best practices?

Validation for skill structure

SKILL.md
Review
Evals

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

Quick Start

# config/environments/development.rb
config.action_controller.perform_caching = true
config.cache_store = :memory_store

# config/environments/production.rb
config.cache_store = :solid_cache_store  # Rails 8 default
# OR
config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }

Enable caching in development:

bin/rails dev:cache

Cache Store Options

StoreUse CaseProsCons
:memory_storeDevelopmentFast, no setupNot shared, limited size
:solid_cache_storeProduction (Rails 8)Database-backed, no RedisSlightly slower
:redis_cache_storeProductionFast, sharedRequires Redis
:file_storeSimple productionPersistent, no RedisSlow, not shared
:null_storeTestingNo cachingN/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)
# Generated key example:
# views/events/123-20240115120000000000/abc123digest

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

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :event, touch: true  # Updates event.updated_at when comment changes
end

# app/models/event_vendor.rb
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

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

# Just read (returns nil if missing)
stats = Rails.cache.read("stats/#{Date.current}")

# Just write
Rails.cache.write("stats/#{Date.current}", stats, expires_in: 1.hour)

# Delete
Rails.cache.delete("stats/#{Date.current}")

In Service Objects

# app/services/dashboard_stats_service.rb
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

# app/queries/dashboard_stats_query.rb
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 includes timestamp, auto-expires when model changes
cache_key = "event/#{event.id}-#{event.updated_at.to_i}"
Rails.cache.fetch(cache_key) { ... }

Manual Invalidation

# In model callback
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

# In service
class Events::UpdateService
  def call(event, params)
    event.update!(params)
    invalidate_related_caches(event)
    success(event)
  end

  private

  def invalidate_related_caches(event)
    Rails.cache.delete("event_count/#{event.account_id}")
    DashboardStatsService.new.invalidate(account: event.account)
  end
end

Pattern-Based Deletion

# Delete all keys matching pattern (Redis only)
Rails.cache.delete_matched("dashboard/*")

# For Solid Cache / Memory Store, use namespaced keys
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])

    # Returns 304 Not Modified if unchanged
    if stale?(@event)
      respond_to do |format|
        format.html
        format.json { render json: @event }
      end
    end
  end

  def index
    @events = current_account.events.recent

    # With custom ETag
    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])

    # Public caching (CDN can cache)
    expires_in 1.hour, public: true

    # Private caching (browser only)
    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

# 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

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

Testing Caching

Spec Configuration

# spec/rails_helper.rb
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)

    # First request - cache miss
    get event_path(event)
    expect(response.body).to include(event.name)

    # Update event
    event.update!(name: "New Name")

    # Second request - should show new name (cache invalidated)
    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

      # Prime cache
      service.call(account: account)

      # Invalidate
      service.invalidate(account: account)

      # Verify cache miss
      expect(Rails.cache.exist?("dashboard_stats/#{account.id}")).to be false
    end
  end
end

Performance Monitoring

Cache Hit/Miss Logging

# config/environments/production.rb
config.action_controller.enable_fragment_cache_logging = true

Custom Instrumentation

# Subscribe to cache events
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

  • Cache store configured for environment
  • Fragment caching on expensive partials
  • 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
  • HTTP caching headers for API
  • Cache warming for cold starts (if needed)
  • Monitoring for hit/miss rates
Repository
fernandezbaptiste/rails_ai_agents
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.