caching
Rails Caching Expert
Implement fast, correct caching in Rails applications. Prefer cache correctness over cache coverage — stale data is worse than slow data.
Philosophy
Core Principles:
- Cache correctness first — A cache miss is slow; stale data is a bug
- Invalidation is the hard part — Every cache you add is a cache you must invalidate
- Measure before caching — Don't cache what isn't slow
- Solid Cache is the Rails 8 default — Use it unless you have a specific reason not to
- Fragment caching is your bread and butter — Start here, not with low-level caching
Caching Hierarchy (prefer top over bottom):
HTTP Caching (stale?/fresh_when) ← Fastest: never even hits your app
Fragment Caching (cache helper) ← Fast: skips view rendering
Collection Caching (cached: true) ← Fast: batch cache reads
Low-Level Caching (Rails.cache) ← Flexible: cache any computation
SQL Caching (automatic per-request) ← Free: Rails does this for you
When To Use This Skill
- Adding caching to views, partials, or expensive computations
- Setting up cache stores (Solid Cache, Redis, Memcached)
- Implementing Russian doll caching with proper invalidation
- Adding HTTP conditional GET support (ETags, Last-Modified)
- Debugging stale cache issues or cache invalidation problems
- Optimizing performance with caching strategies
Instructions
Step 1: Determine What to Cache
Before writing any cache code, identify the bottleneck:
# Check logs for slow queries or renders
grep "Completed" log/development.log | sort -t= -k2 -rn | head -20
# Use rack-mini-profiler or Rails server timing
# Look for: slow partials, N+1 queries, expensive computations
Good candidates for caching:
- Partials rendered in loops (product cards, comment lists)
- Expensive computations (reports, aggregations, API calls)
- Rarely-changing content (navigation, footer, settings)
- External API responses
Bad candidates for caching:
- Content that changes every request (CSRF tokens, flash messages)
- User-specific content without user-scoped keys
- Content behind authentication without proper key scoping
- Anything that writes to the database
Step 2: Choose the Right Cache Strategy
| Strategy | Use When | Cache Key Based On |
|---|---|---|
| Fragment cache | Caching a chunk of view HTML | Record + template digest |
| Collection cache | Rendering a collection of partials | Each record individually |
| Russian doll | Nested fragments with shared invalidation | Record hierarchy with touch |
| Low-level cache | Caching computation results, API calls | Custom key you define |
| Conditional GET | Entire response hasn't changed | ETag or Last-Modified |
Step 3: Implement Fragment Caching
Basic fragment cache:
<% cache product do %>
<%= render product %>
<% end %>
Rails generates a key like: views/products/show:abc123/products/1-20260101120000000000
The key includes:
- Template tree digest (changes when template changes)
- Record's
cache_key_with_version(changes when record updates)
Conditional caching:
<% cache_if !current_user&.admin?, product do %>
<%= render product %>
<% end %>
Multi-part cache keys for user-specific or locale-specific content:
<% cache [current_user, I18n.locale, product] do %>
<%= render product %>
<% end %>
Include the user in the cache key when content varies by user. Without it, you'll cache "Welcome, Ryan" and serve it to every visitor — this is the most common caching mistake.
Step 4: Use Collection Caching for Lists
Instead of caching each item in a loop, use collection caching:
<%# SLOW — one cache read per item %>
<% @products.each do |product| %>
<% cache product do %>
<%= render partial: "products/product", locals: { product: product } %>
<% end %>
<% end %>
<%# FAST — batch cache read (multi-fetch) %>
<%= render partial: "products/product", collection: @products, cached: true %>
Collection caching reads all cache entries in one round trip. This is significantly faster for large collections.
Custom cache keys for collections:
<%= render partial: "products/product",
collection: @products,
cached: ->(product) { [I18n.locale, product] } %>
Step 5: Implement Russian Doll Caching
Russian doll = nested cache fragments where inner cache busting propagates outward via touch.
The view:
<% cache @category do %>
<h2><%= @category.name %></h2>
<% @category.products.each do |product| %>
<% cache product do %>
<%= render product %>
<% end %>
<% end %>
<% end %>
The models — touch: true propagates cache invalidation up the chain:
class Category < ApplicationRecord
has_many :products
end
class Product < ApplicationRecord
belongs_to :category, touch: true
end
When a product updates → its updated_at changes → touch: true updates category's updated_at → both cache fragments expire.
Without touch: true, the outer cache serves stale data — updating a product won't change the category's updated_at, so the category fragment never expires. Every belongs_to in a Russian doll chain needs touch: true.
Multi-level touch chains:
class Store < ApplicationRecord
has_many :categories
end
class Category < ApplicationRecord
belongs_to :store, touch: true
has_many :products
end
class Product < ApplicationRecord
belongs_to :category, touch: true
# Updating a product touches category, which touches store
end
Step 6: Low-Level Caching with Rails.cache
Use fetch — it handles read + write in one call:
class Product < ApplicationRecord
def competing_price
Rails.cache.fetch("#{cache_key_with_version}/competing_price", expires_in: 12.hours) do
Competitor::API.find_price(id)
end
end
end
Key rules for low-level caching:
- Use
cache_key_with_versionfor AR records — it auto-invalidates when the record updates - Set
expires_in— unbounded caches accumulate stale data that's hard to debug - Cache IDs or primitives, not Active Record objects — cached AR objects become stale and break on code reloads
# Avoid — cached AR objects go stale and break on code reloads
Rails.cache.fetch("super_admins", expires_in: 12.hours) do
User.super_admins.to_a
end
# Better — cache IDs, re-query fresh
ids = Rails.cache.fetch("super_admin_ids", expires_in: 12.hours) do
User.super_admins.pluck(:id)
end
User.where(id: ids)
Other cache operations:
Rails.cache.read("key") # Returns nil on miss
Rails.cache.write("key", value, expires_in: 1.hour)
Rails.cache.delete("key")
Rails.cache.exist?("key")
Rails.cache.increment("counter")
Rails.cache.decrement("counter")
# Delete by pattern (not supported by all stores)
Rails.cache.delete_matched("products/*")
# fetch_multi for batch operations
Rails.cache.fetch_multi("key1", "key2", "key3", expires_in: 1.hour) do |key|
expensive_computation(key)
end
Step 7: HTTP Conditional GET (stale? / fresh_when)
This is the most impactful cache — it prevents the response from being generated at all.
Use stale? when you have custom response logic:
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
if stale?(@product)
respond_to do |format|
format.html
format.json { render json: @product }
end
end
# If fresh, Rails auto-sends 304 Not Modified
end
end
Use fresh_when for simple cases (default template rendering):
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
fresh_when @product
# That's it — renders template if stale, 304 if fresh
end
def index
@products = Product.all
fresh_when @products
end
end
With explicit options:
fresh_when last_modified: @product.updated_at.utc, etag: @product.cache_key_with_version
For content that never changes:
def show
http_cache_forever(public: true) do
render
end
end
When to use conditional GET:
- Show pages for individual records
- Index pages with predictable update patterns
- API endpoints where clients cache responses
- Static-ish pages (about, terms, etc.)
Step 8: Configure Cache Store
Solid Cache (Rails 8 Default — Use This)
Database-backed caching using SSDs. No extra infrastructure needed.
# config/environments/production.rb
config.cache_store = :solid_cache_store
# config/database.yml
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
# config/cache.yml
default: &default
store_options:
max_age: <%= 60.days.to_i %>
max_size: <%= 256.megabytes %>
namespace: <%= Rails.env %>
Why Solid Cache:
- Zero infrastructure — it's just your database
- Larger storage than RAM-based stores (SSDs are cheap)
- FIFO eviction with configurable max_age
- Supports encryption for sensitive cached data
- Supports sharding for horizontal scaling
Other stores: Redis (:redis_cache_store), Memory (:memory_store), Null (:null_store). See references/cache-stores.md for Redis production configuration with error handling, and full store comparison.
Step 9: Enable Caching in Development
bin/rails dev:cache # Toggle caching on/off
This creates/removes tmp/caching-dev.txt and toggles perform_caching.
To use Solid Cache in development:
# config/environments/development.rb
config.cache_store = :solid_cache_store
And add the cache database to config/database.yml under development.
Step 10: Test Cache Behavior
Test that caching works correctly, not that Rails caching works:
class ProductTest < ActiveSupport::TestCase
test "competing_price is cached" do
product = products(:widget)
# First call hits the API
assert_equal 29.99, product.competing_price
# Verify cache was written
assert Rails.cache.exist?("#{product.cache_key_with_version}/competing_price")
end
test "touch propagation invalidates parent cache" do
category = categories(:electronics)
product = products(:widget)
old_updated_at = category.updated_at
product.update!(name: "New Name")
assert_operator category.reload.updated_at, :>, old_updated_at
end
end
In request tests, verify conditional GET:
class ProductsControllerTest < ActionDispatch::IntegrationTest
test "returns 304 when product unchanged" do
product = products(:widget)
get product_path(product)
assert_response :success
etag = response.headers["ETag"]
get product_path(product), headers: { "HTTP_IF_NONE_MATCH" => etag }
assert_response :not_modified
end
end
Common Mistakes
1. Caching User-Specific Content Without User Key
<%# Wrong: all users see the same cached content %>
<% cache @dashboard do %>
Welcome, <%= current_user.name %>!
<%= render @dashboard.widgets %>
<% end %>
<%# Fixed: include user in cache key %>
<% cache [current_user, @dashboard] do %>
Welcome, <%= current_user.name %>!
<%= render @dashboard.widgets %>
<% end %>
2. Missing touch on Associations
# Problem: updating a comment doesn't expire the post cache
class Comment < ApplicationRecord
belongs_to :post
end
# Fixed: touch propagates invalidation to parent
class Comment < ApplicationRecord
belongs_to :post, touch: true
end
3. Caching Active Record Objects
# Problem: cached AR objects become stale and break on code reload
Rails.cache.fetch("featured") { Product.featured.to_a }
# Fixed: cache IDs, re-query fresh
ids = Rails.cache.fetch("featured_ids", expires_in: 1.hour) { Product.featured.pluck(:id) }
Product.where(id: ids)
4. No Expiration on Low-Level Cache
# Problem: cache lives forever, exchange rates go stale silently
Rails.cache.fetch("exchange_rates") { ExchangeRateAPI.current }
# Fixed: set expires_in so data refreshes
Rails.cache.fetch("exchange_rates", expires_in: 1.hour) { ExchangeRateAPI.current }
See references/fragment-caching.md for additional common mistakes including cache key dependency issues and helper digest gotchas.
Quick Reference: Cache Key Methods
| Method | Returns | Use For |
|---|---|---|
cache_key |
products/1 |
Stable key (no version) |
cache_key_with_version |
products/1-20260101120000 |
Key + timestamp version |
cache_version |
20260101120000 |
Just the version stamp |
to_param |
"1" |
Fallback for non-AR objects |
Reference
For detailed patterns, edge cases, and advanced configuration, see the references/ directory:
references/fragment-caching.md— Cache keys, composite keys, collection caching, dependenciesreferences/russian-doll.md— Nested caching, touch propagation, counter cachesreferences/low-level-caching.md— Rails.cache.fetch, fetch_multi, atomic operationsreferences/conditional-get.md— stale?, fresh_when, ETags, HTTP cachingreferences/cache-stores.md— Store comparison, Solid Cache, Redis, Memcached configreferences/performance.md— N+1 cache calls, warming, stampede prevention, debugging
More from thinkoodle/rails-skills
stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4active-model
Build plain Ruby objects that integrate with Rails forms, validations, serialization, and Action Pack — without a database. Use when building form objects, search forms, API wrappers, configuration objects, virtual models, or any PORO that needs model-like behavior. Triggers on "active model", "form object", "plain Ruby object", "non-database model", "ActiveModel", "PORO", "service object validation", "virtual model", "tableless model", "search form", "ActiveModel::API", "ActiveModel::Model".
4css-architecture
Expert guidance for CSS architecture in Rails applications using modern CSS (no Tailwind). Use when writing stylesheets, organizing CSS files, implementing dark mode, defining design tokens, using CSS custom properties, creating component styles, working with CSS layers (@layer), using the light-dark() function, setting up color schemes, or structuring CSS for maintainability. Covers design tokens, semantic color systems, component-scoped styles, utility classes, responsive patterns, and file organization.
4migrations
Expert guidance for writing safe, reversible Active Record migrations in Rails applications. Use when creating a migration, adding a column, removing a column, changing schema, modifying a table, creating a table, adding an index, adding a foreign key, renaming a column, changing column type, database migration, schema change, rolling back, migration error, data migration, multi-database migration, or any database structure change.
4