home / skills / thibautbaissac / rails_ai_agents / caching-strategies

caching-strategies skill

/skills/caching-strategies

This skill helps you implement Rails caching patterns to boost performance with fragment, Russian doll, and low-level caching strategies.

npx playbooks add skill thibautbaissac/rails_ai_agents --skill caching-strategies

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
10.7 KB
---
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

## Quick Start

```ruby
# 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:
```bash
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

```erb
<%# 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)

```ruby
# Generated key example:
# views/events/123-20240115120000000000/abc123digest
```

### Custom Cache Keys

```erb
<%# 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:

```erb
<%# 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

```ruby
# 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

```erb
<%# 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

```erb
<%= render partial: "events/event",
           collection: @events,
           cached: ->(event) { [event, current_user.admin?] } %>
```

## Low-Level Caching

### Basic Read/Write

```ruby
# 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

```ruby
# 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

```ruby
# 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

```ruby
Rails.cache.fetch("key", expires_in: 1.hour) { ... }
```

### Key-Based Expiration

```ruby
# 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

```ruby
# 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

```ruby
# 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)

```ruby
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

```ruby
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

```ruby
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

```ruby
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

```ruby
# 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

```ruby
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

```ruby
# 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

```ruby
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

```ruby
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

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

### Custom Instrumentation

```ruby
# 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

Overview

This skill implements Rails caching patterns to improve application performance and reduce DB work. It provides concrete patterns for fragment and Russian doll caching, low-level caching, HTTP caching, memoization, and cache invalidation. Use it to standardize cache keys, TTLs, and invalidation strategies across a Rails 8.1 app.

How this skill works

The skill inspects view partials, models, service/query objects, and controller actions to recommend where to apply fragment, collection, and low-level caches. It suggests cache stores and configuration for environments, generates safe cache keys and namespacing, and outlines invalidation techniques like touch: true, manual deletes, and time-based expirations. It also provides testing and monitoring guidance for hit/miss visibility.

When to use it

  • Rendering expensive partials or lists that rarely change
  • Optimizing nested content with Russian doll caching
  • Caching expensive query results or aggregated stats
  • Designing cache invalidation after model updates
  • Adding HTTP cache headers or ETag/Last-Modified support
  • Writing tests that assert cache behavior and invalidation

Best practices

  • Prefer stable cache keys that include model id and updated_at or explicit version tokens
  • Use touch: true on child models to cascade invalidation in Russian doll patterns
  • Choose a cache store appropriate for environment (memory for dev, solid_cache_store or Redis for production)
  • Keep TTLs conservative for dynamic data and use manual invalidation for critical updates
  • Namespace keys per account/tenant and use delete_matched only with supported stores (Redis)
  • Instrument cache reads/writes to monitor hit/miss rates and guide tuning

Example use cases

  • Wrap an expensive event partial: cache event do ... end to avoid repeated rendering
  • Cache dashboard aggregates in a service with Rails.cache.fetch and an account-scoped key
  • Render a collection with cached: true so each item reuses its fragment cache
  • Add touch: true to comments so updating comments invalidates the parent event fragment
  • Use stale?(etag: ..., last_modified: ...) in controllers for 304 responses and CDN-friendly caching

FAQ

When should I use Redis instead of the default solid cache?

Use Redis when you need a shared, fast cache across processes or machines. solid_cache_store is fine for simpler deployments where a DB-backed store is acceptable.

How do I handle per-user content in view caching?

Include user-specific context in the cache key (for example [record, current_user]) or avoid fragment caching for user-personalized regions and use client-side techniques.