Let’s talk about making your Rails application faster. Much faster. The secret often isn’t a fancier database or more servers; it’s storing answers you already know so you don’t have to figure them out again and again. This is caching. I think of it like keeping a frequently used phone number on a sticky note instead of looking it up in the directory every single time.
When your app starts to slow down under load, the database is usually the first place to feel the strain. Every page render asking for the same set of popular products is doing repetitive, expensive work. Caching is how we stop that waste. But it’s not just about speed. It’s about resilience, scalability, and providing a smooth experience even when parts of your system are busy or slow.
Over the years, I’ve moved from simple tricks to more sophisticated methods. The journey usually starts with caching a bit of HTML and evolves into a full strategy for data. Here, I’ll share several methods I use, from the foundational to the complex, complete with code to show you exactly how they work.
The first and most visual method is often called Russian doll caching. Picture one of those nested wooden dolls. The big outer doll contains a slightly smaller one, which contains another, and so on. In your Rails views, you can do the same with cached fragments.
Let’s say you have a page listing products. You could cache the entire list. But if one product changes, you’d have to regenerate the whole list. Instead, cache the outer container and each product inside it independently.
# app/views/products/index.html.erb
<% cache ["products_index", @products.maximum(:updated_at)] do %>
<h1>Our Products</h1>
<div class="products">
<% @products.each do |product| %>
<% cache product do %>
<div class="product">
<h2><%= product.name %></h2>
<p><%= product.description %></p>
</div>
<% end %>
<% end %>
</div>
<% end %>
The outer cache uses a key based on the latest update time of any product in the collection. The inner cache for each product uses the product object itself. Rails is smart here. When you pass an Active Record object to the cache helper, it generates a key based on the model name, its ID, and its updated_at timestamp.
If you update “Product #5,” its updated_at changes. The next time the page is requested, the outer cache key is still based on the old maximum updated_at, so it tries to use the cached outer shell. But when it goes to render “Product #5,” the cache key for that specific object is now different (product/5-1498765432 vs. product/5-1498765431). It’s a miss. Rails generates a new cached fragment for just that one product and stitches it into the older, still-valid outer cached shell. You only recompute what changed.
Rails makes collection caching even cleaner with the cached: true option on render.
<%= render partial: "products/product", collection: @products, cached: true %>
This single line does the work of the loop above. It automatically caches each rendered partial using a key derived from the object and the partial template. It’s incredibly efficient for lists.
Beyond HTML, you often perform heavy calculations or complex queries. You don’t want to run that report every time someone views a dashboard. This is where low-level, programmatic caching comes in. You use Rails.cache.fetch to store any data you want.
Imagine you need to display sales statistics for a product. The query to calculate this is complex and slow.
class Product
def daily_sales_data
Rails.cache.fetch("product/#{id}/daily_sales", expires_in: 1.hour) do
# This block runs only if the cache is empty or expired
Order.where(product_id: id)
.where("created_at >= ?", 1.day.ago)
.group_by_hour(:created_at)
.sum(:quantity)
end
end
end
The first call to product.daily_sales_data will run the query inside the block and store the result. For the next hour, any call will instantly return the stored data. After an hour, the entry expires, and the next call runs the block again to get fresh data.
A lesson I learned the hard way: when you change the structure of the data you’re caching, you need to change the cache key. If you don’t, your app might try to use old, malformed data. I now include a version in my cache keys for structured data.
class ProductSerializer
CACHE_VERSION = "2023-10".freeze # Change this when the structure changes
def serialized_product(product)
cache_key = "serialized/product/#{product.id}-#{product.updated_at.to_i}-#{CACHE_VERSION}"
Rails.cache.fetch(cache_key) do
{
id: product.id,
name: product.name,
price: product.price_cents, # Changed from `price`
in_stock: product.inventory > 0
}
end
end
end
Now, when I change the format from price to price_cents, I bump CACHE_VERSION to "2023-11". All old cache entries are ignored because the key won’t match, and new ones are created with the correct structure. It’s a clean, controlled way to handle change.
Modern apps rarely live alone. They talk to payment gateways, weather APIs, or social media platforms. These external calls are slow and can fail. Caching here is a stability feature as much as a performance one.
I wrap external API clients in a caching layer. The pattern is called “read-through.” You try to read from the cache first. On a miss, you call the API, store the result, and then return it.
class WeatherService
def forecast_for(zip_code)
cache_key = "weather/forecast/#{zip_code}"
cached = Rails.cache.read(cache_key)
return cached if cached
# This is the slow, external call
fresh_forecast = ExternalWeatherApi.get_forecast(zip_code: zip_code)
# Store it for 30 minutes
Rails.cache.write(cache_key, fresh_forecast, expires_in: 30.minutes)
fresh_forecast
end
end
You can make this more robust. What if the API is down? You might decide to serve slightly old data rather than show an error. This is “graceful stale” behavior.
def forecast_for(zip_code)
cache_key = "weather/forecast/#{zip_code}"
begin
fresh_forecast = ExternalWeatherApi.get_forecast(zip_code: zip_code)
Rails.cache.write(cache_key, fresh_forecast, expires_in: 30.minutes)
fresh_forecast
rescue Timeout::Error => e
# API call failed. Return stale data if we have it.
Rails.logger.warn("API failed, attempting stale cache for #{zip_code}")
stale = Rails.cache.read(cache_key)
return stale if stale
# No stale data available, we have to raise the error
raise e
end
end
This pattern has saved me during third-party service outages. The user might see weather from 30 minutes ago, but they see something, which is often better than an error page.
Here’s a subtle problem: a cache entry expires. At that exact moment, 10 web server processes all see the cache is empty. All 10 start running the same expensive calculation or query to repopulate it. Your system gets hit with 10 times the load it expected. This is a cache stampede.
You prevent it by not letting all entries expire at the same time. Add a bit of randomness, or better, have entries renew a little before they actually expire.
class StableCache
def fetch_complex_report(key)
# Try to read the value AND its metadata
entry = Rails.cache.read("meta_#{key}")
if entry.nil?
# Cache is completely cold. Generate it.
generate_and_store_report(key)
elsif entry[:expires_at] < Time.current + 5.minutes
# Cache is getting old (will expire in less than 5 min).
# Use the current value, but trigger a background refresh.
BackgroundRefreshJob.perform_later(key)
entry[:value]
else
# Cache is fresh.
entry[:value]
end
end
private
def generate_and_store_report(key)
value = ComplexReportCalculator.new(key).run
# Store with a random expiry between 55 and 65 minutes
expires_at = Time.current + 55.minutes + rand(10 * 60)
Rails.cache.write("meta_#{key}", {
value: value,
expires_at: expires_at,
generated_at: Time.current
}, expires_in: 70.minutes) # Store longer than max expiry
value
end
end
The key is storing metadata about when the value was generated and when it should expire. If a process sees the data is “warm” but will expire soon, it returns the current value and queues a job to refresh it in the background. Only one background job will run at a time (thanks to the job queue), and the website traffic keeps getting fast responses from the slightly-stale cache. It smooths out the load perfectly.
If caching is about storing answers, cache invalidation is about knowing when those answers become wrong. It’s the hardest part. A product’s price changes, and suddenly every page showing the old price is wrong. You need a strategy to delete or update cached data.
The simplest way is to tie cache keys to the updated_at timestamp of your models, as we saw earlier. When the product updates, its cache key changes, and the old stored fragment is simply ignored. It lives in the cache until it’s garbage-collected, but it’s never used again.
Sometimes you need to be more aggressive and delete things. For example, you might have a cached sidebar showing “Recent News.” When you publish a new article, you need to wipe that specific cache.
class Article < ApplicationRecord
after_commit :clear_sidebar_cache, on: [:create, :update]
private
def clear_sidebar_cache
Rails.cache.delete("sidebar/recent_articles")
# Also clear any CDN or edge caches
EdgeCache.purge("/sidebar")
end
end
For more complex relationships, you might need to clear multiple things. Updating a product might invalidate the product page, its category listing, and the search index.
class ProductCacheInvalidator
def after_update(product)
# The product's own page
Rails.cache.delete_matched("views/products/#{product.id}-*")
# Any category page it belongs to
product.categories.each do |cat|
Rails.cache.delete("category/#{cat.id}/products")
end
# The site-wide product listing
Rails.cache.delete("all_products_sorted")
end
end
Using delete_matched with a pattern (like "views/products/#{product.id}-*") can be powerful but is slower on some cache stores (like Redis) if you have many keys. Sometimes it’s better to track these relationships explicitly. I might store a list of cache keys related to a product in a set, then delete them all at once when needed.
So far, we’ve talked about caching reads. But what about writes? Sometimes the bottleneck is writing data to a slow persistent store, like a remote database or a legacy API. A write-behind cache can absorb writes quickly and then flush them to the backend in batches, asynchronously.
You accept the write into a fast, local in-memory buffer and immediately acknowledge it to the user. Later, a background process drains the buffer and writes the data to the final destination.
class WriteBuffer
def initialize
@buffer = {}
@mutex = Mutex.new
start_flusher_thread
end
def write(key, value)
@mutex.synchronize do
@buffer[key] = value
end
# Returns immediately, write is "done" from the caller's perspective
end
def read(key)
# Check buffer first for fresh writes
@mutex.synchronize do
return @buffer[key] if @buffer.key?(key)
end
# If not in buffer, read from the true source
PersistentStore.read(key)
end
private
def start_flusher_thread
Thread.new do
loop do
sleep 60 # Flush every minute
flush_buffer
end
end
end
def flush_buffer
batch = nil
@mutex.synchronize do
return if @buffer.empty?
batch = @buffer.dup
@buffer.clear
end
# Write the whole batch to the slow store
PersistentStore.write_multi(batch)
end
end
This is an advanced pattern with clear trade-offs. You gain huge write speed and can handle spikes, but you risk losing data if your application crashes before the buffer is flushed. It’s only suitable for certain types of non-critical, ephemeral data where speed is paramount and absolute durability is not required.
After a new deployment, your server restarts. The cache is empty—a “cold cache.” The first users to arrive trigger all the expensive computations we set up caching to avoid. They have a slow experience, and your server groans under the sudden load. We can warm the cache.
A cache warmer is a script that runs after deployment to pre-compute and store the most important cached values before real users arrive.
# lib/tasks/cache.rake
namespace :cache do
desc "Warm up critical caches after deployment"
task warm: :environment do
puts "Warming product caches..."
Product.find_each do |product|
product.daily_sales_data # This method caches its result
end
puts "Warming fragment caches..."
# Pre-render common view fragments
ApplicationController.new.render_to_string partial: "shared/header"
ApplicationController.new.render_to_string partial: "shared/footer"
puts "Warming API response caches..."
WeatherService.new.forecast_for("90210")
puts "Cache warm complete."
end
end
You run this task as part of your deployment process, right after the new code is deployed but before you direct live traffic to it. It makes the transition seamless for users.
Finally, how do you know if your caching is working? You monitor it. You track hits, misses, and the size of your cache.
I instrument my cache calls to see what’s happening. A low hit rate means I’m not caching the right things or my cache keys are changing too often. A sudden spike in misses might indicate a problem with invalidation.
# An instrumented cache wrapper
class MonitoredCache
def fetch(key, expires_in:)
value = Rails.cache.read(key)
if value
StatsD.increment("cache.hit")
return value
else
StatsD.increment("cache.miss")
value = yield # Calculate the value
Rails.cache.write(key, value, expires_in: expires_in)
return value
end
end
end
I send these metrics to a monitoring system. I might also log unusually slow cache generation times to identify expensive operations that are prime candidates for optimization.
Caching is a journey. You start simple, perhaps with a fragment cache on your homepage. As your application grows, you layer in these more advanced strategies. Each one solves a specific problem: Russian doll caching for efficient HTML, low-level caching for data, read-through for external services, stampede prevention for stability, intelligent invalidation for correctness, write-behind for write performance, and warming for smooth deployments.
The goal is never to cache everything, but to cache the right things thoughtfully. Good caching makes your application feel instant, resilient, and scalable. It turns what could be a frustrating wait into a smooth interaction. And in the end, that’s what we’re building for—the person using the application, who should never have to think about the complex machinery working to make their experience fast and reliable.