Rails Caching: Speed Up Your App with Fragment and Russian Doll
Rails caching is one of the most effective ways to improve Rails application performance. Without it, every request rebuilds views, queries the database, and processes data that rarely changes. With proper caching, you serve cached content instantly and reduce load on your database and application server.
This guide covers the main caching strategies in Rails: low-level caching, fragment caching, action caching, page caching, and the powerful Russian doll pattern.
The most important habit is to choose the smallest cache that still removes the expensive work. A cache that is too broad is hard to expire and easy to trust for too long. A cache that is too narrow barely helps. The sweet spot is usually a record, a fragment, or a query result that changes less often than the page around it. That balance matters more than the exact storage backend.
Low-Level Caching
Low-level caching gives you fine-grained control over what gets cached. The Rails.cache interface provides a unified API across different storage backends (memory, Redis, Memcached, file store).
class User < ApplicationRecord
def self.top_contributors(limit: 10)
cache_key = "users/top_contributors/#{limit}"
Rails.cache.fetch(cache_key, expires_in: 1.hour) do
where('posts_count > 0').order(posts_count: :desc).limit(limit).to_a
end
end
end
This example caches the result of an expensive query for one hour. The cache_key ensures each combination of parameters gets its own cache entry.
That tiny key detail is what keeps the cache useful. If the key does not include the values that change the result, stale data becomes very likely. If it includes too much, you miss opportunities to reuse work. Good cache keys usually reflect the thing being cached, the inputs that affect it, and a version or timestamp when invalidation needs help.
# Reading from cache
Rails.cache.read("users/top_contributors/10")
# => [#<User id: 1>, #<User id: 5>, ...]
# Writing directly
Rails.cache.write("my_key", "some value", expires_in: 30.minutes)
# Deleting
Rails.cache.delete("my_key")
The Rails.cache interface wraps read, write, and delete operations behind method calls that work the same way regardless of whether the backing store is Redis, Memcached, or a file on disk. This abstraction means you can swap cache backends without changing any caching calls in your application code.
Choose your cache store in config/environments/production.rb:
config.cache_store = :redis_cache_store, { url: ENV.fetch("REDIS_URL") }
For development, the default memory store works fine. For production, Redis is the standard choice.
Once the store is configured, treat cache reads and writes like any other dependency. A cache should make the code faster, but it should not become a hidden source of correctness. If the application still works when the cache misses, you are using the cache in the right place.
Fragment Caching
Fragment caching lets you cache portions of a view. When most of a page stays the same but a section changes per user or request, fragment caching shines.
<%# app/views/products/show.html.erb %>
<%= render @product %>
<% cache ['product', @product, 'details'] do %>
<div class="product-details">
<p>Category: <%= @product.category.name %></p>
<p>Reviews: <%= @product.reviews.count %></p>
</div>
<% end %>
<% if current_user %>
<%= render 'user_actions' %>
<% end %>
The cache key includes @product and a version string. When @product updates, the cache automatically expires.
That pattern is useful because the view fragment can stay simple while the invalidation logic lives in the data model. The cache key should describe the state of the fragment, not just the name of the template. When the model changes, the key changes with it, and the fragment naturally falls out of the cache.
You can also cache collections efficiently:
<% cache ['products', @productsMaximum_cache_key] do %>
<% @products.each do |product| %>
<%= render product %>
<% end %>
<% end %>
Action and page caching
Action caching stores the entire output of an action. It is useful for pages that do not need per-user customization. The cached response skips the view rendering layer entirely, which can cut response times by a large margin for expensive template hierarchies.
class ProductsController < ApplicationController
# Cache the entire action output
caches_action :index, expires_in: 10.minutes
# Only cache for guest users
caches_action :show, if: -> { current_user.nil? }
# Skip caching for authenticated users
caches_action :dashboard, unless: -> { current_user.nil? }
# Clear specific action caches
def invalidate_index
expire_action action: :index
end
end
Action caching lives inside Rails and still runs middleware, filters, and before-actions. That makes it safer than page caching for endpoints that need request-level logic but still benefit from skipping the view. Page caching trades that flexibility for raw speed.
Page caching goes further; it serves static HTML files directly from the filesystem or CDN, bypassing Rails entirely:
class ProductsController < ApplicationController
caches_page :index, :show
# After creating/updating a product, expire the page cache
after_action :expire_cache, only: [:create, :update, :destroy]
private
def expire_cache
expire_page action: :index
expire_page action: :show, id: params[:id]
end
end
Note: Action and page caching are less common in modern Rails apps because they bypass the application entirely, making it harder to expire caches dynamically. Fragment caching offers more flexibility.
This tradeoff matters most when you expect personalized content or rapid data changes. A page cache can be very fast, but it also removes a lot of room for small exceptions. Fragment caching is usually easier to reason about because it lets Rails build the response from a mixture of fresh and reused pieces.
Russian doll caching
Russian doll caching nests cached fragments inside each other. When you update an inner fragment, outer fragments automatically expire. This creates a cascade of cache invalidation.
<%# Outer cache: product list %>
<% cache ['products', @products] do %>
<div class="products-grid">
<% @products.each do |product| %>
<%# Inner cache: individual product %>
<% cache ['product', product] do %>
<div class="product-card">
<h3><%= product.name %></h3>
<p><%= product.price %></p>
<%# Innermost: product reviews %>
<% cache ['product-reviews', product.reviews] do %>
<%= render product.reviews %>
<% end %>
</div>
<% end %>
<% end %>
</div>
<% end %>
When a single review updates, only the innermost cache key expires. The product list and product card caches automatically expire because their dependencies changed.
That nested structure is powerful, but it works best when each level has a clear owner. The inner cache should represent the most specific object, while the outer cache should represent the larger collection or layout. If those boundaries blur, invalidation gets harder to predict and the savings are not as reliable.
This pattern dramatically reduces cache invalidation complexity:
# When a review is created
@review = Review.create!(...)
# => Cache key ['product-reviews', @review.product.reviews] changes
# => Outer product cache expires automatically
# => Products list cache expires automatically
Cache expiration strategies
Choosing when to expire caches is crucial. Here are the main approaches:
The expiration strategy should match how the data changes. If the page changes on a schedule, time-based expiration is enough. If it changes when a record updates, version-based or event-based expiration usually gives better control. The point is not to find a single universal rule. The point is to make the invalidation rule easy to explain to the next person who reads the code.
Time-based expiration is simple but can serve stale data:
Rails.cache.fetch(key, expires_in: 1.hour) { compute }
Time-based expiration is the simplest strategy and works well for data that follows a predictable freshness window. The tradeoff is that stale data persists for the full duration of the window, which means users can see outdated content for up to an hour in the example above.
Version-based expiration ties cache to data versions:
cache_key = "product/#{product.id}/v#{product.version}"
Version-based expiration avoids stale reads by changing the cache key whenever the underlying data changes. This works especially well with Active Record’s cache_key_with_version, which includes the record’s updated_at timestamp automatically. The cache fills a new slot when the key changes, and the old entry eventually ages out.
Event-based expiration invalidates on specific actions:
# In an Observer or callback
after_save { Rails.cache.delete("products/#{id}") }
after_destroy { Rails.cache.delete("products/#{id}") }
Event-based expiration gives you fine control because you can decide exactly which callbacks fire a cache delete. This approach works well when the cache key cannot encode all the reasons the data might change, or when the data change is too rare to justify a version bump on every save.
Sweeper pattern for action caching:
# app/sweepers/product_sweeper.rb
class ProductSweeper < ActionController::Caching::Sweeper
observe Product
def after_save(product)
expire_cache_for(product)
end
def after_destroy(product)
expire_cache_for(product)
end
private
def expire_cache_for(product)
expire_action controller: :products, action: :index
expire_action controller: :products, action: :show, id: product.id
end
end
Practical Tips
Start with low-level caching for expensive database queries. Move to fragment caching for view sections that are expensive to render. Only use action or page caching when the entire response can be cached.
Monitor your cache hit rate in production. A low hit rate means your cache keys are too specific or expiration is too aggressive.
# Add to a Rails controller to monitor
def index
@products = Product.all
respond_to do |format|
format.html
format.json { render json: { cached: @products, stats: Rails.cache.stats } }
end
end
Avoid caching per-user data in shared caches like Redis unless you namespace keys by user ID. Otherwise, users might see each other’s data.
One final rule helps keep cached code healthy: make the uncached path correct first. If the app behaves well without the cache, then the cache can be treated as an optimization layer instead of part of the core business logic. That keeps the system easier to test and easier to debug when a cache entry disappears or goes stale.
When you are unsure where to start, cache the thing that costs the most to compute and changes the least often. In many Rails apps that is a database-backed collection, a rendered partial, or a derived count. The exact mechanism matters less than the habit of measuring the expensive path first, then deciding whether the cached shape should live in the model, the controller, or the view.
That habit keeps the codebase from drifting toward blanket caching. A focused cache is easier to expire, easier to explain, and easier to remove if the data flow changes later. In practice, that usually means a small number of well-named keys and a short note about why each key exists.
See Also
- /guides/ruby-struct-guide/, Struct provides lightweight data objects useful for cache key construction
- /guides/ruby-service-objects/, Service objects help organize caching logic outside of controllers
- /guides/ruby-command-pattern/, The command pattern complements caching by encapsulating cache-invalidating operations