← Back to Blog

You Added Rails Caching. Is It Actually Working?

October 13, 2026

You wrapped the slow partial in a cache block. You moved the expensive dashboard query into Rails.cache.fetch. You deployed, opened the page, and it's… about as slow as before.

Caching is one of the most effective performance tools Rails gives you, and also one of the easiest to get quietly wrong. A cache that never hits isn't free: every miss still does the original work, plus a read from the cache store, plus a write back into it. If the key changes on every request, you've added overhead and called it an optimization.

This guide is about the part most caching tutorials skip: proving that your cache works in production, and figuring out why when it doesn't.

Three questions to ask of every cache

Before you look at any tooling, it helps to know what you're checking. Every cache in your app should be able to answer three questions:

  • Is it hitting? How often does a read find a value instead of rebuilding it?
  • Is the hit worth it? Is the cached work expensive enough that skipping it changes the response time?
  • Is it still correct? When the underlying data changes, does the cache expire, or does it serve stale content?

Most caching bugs fail one of these three. Low hit rates fail the first. Caching a cheap computation behind a network round trip fails the second. A fragment that never updates after a record changes fails the third.

A quick refresher on what Rails gives you

If you've used Rails caching before, skim this. Everything below refers to these four building blocks, all covered in the Rails caching guide.

Low-level caching with Rails.cache.fetch runs the block only on a miss and stores the result:

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

cache_key_with_version includes the record's updated_at, so updating the product produces a new key and the old value is simply never read again.

Fragment caching wraps part of a view:

<% @products.each do |product| %>
  <% cache product do %>
    <%= render product %>
  <% end %>
<% end %>

Collection caching does the same for a whole collection, but reads every entry in a single multi-fetch instead of one read per item:

<%= render partial: "products/product", collection: @products, cached: true %>

Russian doll caching nests fragments inside fragments. For an outer fragment to expire when an inner record changes, the records need to be tied together with touch:

class Review < ApplicationRecord
  belongs_to :product, touch: true
end

Step 1: Watch it work in development first

By default, Action Controller caching is only enabled in production. If you've never seen your cache hit locally, start there:

bin/rails dev:cache

Run it again to toggle caching off. Note that this only affects Action Controller caching (fragments); Rails.cache uses whatever config.cache_store your development environment is set to.

With caching on, Rails' development log already tells you a lot. Partials that use a cache block are annotated with [cache hit] or [cache miss], and cached collections report how many entries came from the cache:

Rendered collection of products/_product.html.erb [18 / 25 cache hits] (Duration: 4.1ms | GC: 0.0ms)

Reload the page twice. The second load should be mostly hits. If it isn't, you have a key problem, and it's much easier to debug on your laptop than in production. Jump to step 3.

Step 2: Measure hit rate in production

Development tells you whether the mechanism works. Production tells you whether it works on real traffic, with real users, real data churn, and a real store that can fill up and evict entries.

Rails instruments every cache operation through ActiveSupport::Notifications. The events you care about are documented in the instrumentation guide: cache_read.active_support carries a :hit flag, and cache_read_multi.active_support (used by collection caching and fetch_multi) carries the list of keys that hit in :hits. Fragment caching goes through the same cache store, so it shows up here too.

That's enough to build a small per-request hit counter. First, a CurrentAttributes class to hold the counts. Rails resets these automatically around every request and job, so counts never leak between them:

# app/models/cache_stats.rb
class CacheStats < ActiveSupport::CurrentAttributes
  attribute :reads, :hits, :misses

  def self.record(key, hit)
    self.reads = (reads || 0) + 1

    if hit
      self.hits = (hits || 0) + 1
    else
      (self.misses ||= Hash.new(0))[family(key)] += 1
    end
  end

  # Collapse record IDs, timestamps and template digests so
  # "products/12-20261001.../competing_price" and
  # "products/97-20260930.../competing_price" count as one key family.
  def self.family(key)
    key.to_s.gsub(/\h{32,}/, "<digest>").gsub(/\d+/, "<n>")
  end

  def self.summary
    return if reads.to_i.zero?

    top_misses = (misses || {}).sort_by { |_, count| -count }.first(3)
                               .map { |family, count| "#{family} x#{count}" }

    line = "[cache] #{hits.to_i}/#{reads} hits"
    line << " | misses: #{top_misses.join(', ')}" if top_misses.any?
    line
  end
end

Then subscribe to the two read events:

# config/initializers/cache_stats.rb
ActiveSupport::Notifications.subscribe("cache_read.active_support") do |event|
  CacheStats.record(event.payload[:key], event.payload[:hit])
end

ActiveSupport::Notifications.subscribe("cache_read_multi.active_support") do |event|
  hits = Array(event.payload[:hits])
  Array(event.payload[:key]).each do |key|
    CacheStats.record(key, hits.include?(key))
  end
end

And log the summary at the end of each request:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  after_action do
    summary = CacheStats.summary
    logger.info(summary) if summary
  end
end

Now every request that touches the cache leaves a line like this next to its normal log output:

[cache] 3/9 hits | misses: views/products/index:<digest>/products/<n> x5, stats/<n>/revenue x1

A few notes before you ship it:

  • This is a diagnostic, not a metrics system. It tells you about one request at a time. If you want trends, send the same counts to whatever you already use for metrics, tagged by controller action. Be careful about tagging by key family in a metrics system, since unbounded tag values get expensive fast.
  • The family normalization is deliberately crude. Adjust the regexes to match your key conventions. The goal is only to group "the same cache, different record" together.
  • Background jobs use Rails.cache too. Add the same summary to an around_perform in ApplicationJob if your jobs cache heavily.

There's no universal "good" hit rate. A cache for a page everyone views should hit almost always; a per-user cache will miss on every user's first visit. What you're looking for is a key family that never hits, or one whose misses dominate a slow endpoint.

Step 3: Why caches miss (and how to fix each one)

Once you can see misses grouped by key family, the cause is usually one of these.

The key changes on every request

The most common bug. Anything that varies per request (a timestamp, a request ID, a full params hash with tracking parameters) makes every key unique:

# Every request gets a new key, so this never hits
Rails.cache.fetch(["revenue", Time.current]) { Report.revenue }


<%# Includes utm_source, page, and anything else in the query string %>
<% cache [@report, params] do %>

Fix it by keying on the things that actually change the output: the record (which brings its updated_at), and only the specific parameters that matter.

Rails.cache.fetch(["revenue", Date.current], expires_in: 1.hour) { Report.revenue }


<% cache [@report, params[:period]] do %>

The normalized family in your log line makes this easy to spot: the same family appears as a miss on every request, with no hits at all.

The store isn't the store you think it is

Check what your production app is actually using:

# bin/rails runner -e production
puts Rails.cache.class

Two surprises come up often. :null_store accepts writes and stores nothing, so every read is a miss. And :memory_store keeps entries inside each Ruby process: with Puma in clustered mode, every worker has its own separate cache, so a value cached by one worker is a miss in the others.

New Rails 8 apps default to Solid Cache, a database-backed store. It's a good fit for a large cache without running Redis or Memcached, but it evicts entries first-in, first-out once it reaches its size limit, rather than by least recent use. If you rely on it, make sure its configured size fits the working set you expect to keep.

Entries are evicted before they're reused

If a key family hits sometimes but less than you'd expect, the store may be full. Every bounded store evicts something when it runs out of room. The fix is either more space or fewer, smaller entries. Caching large rendered pages per user is a quick way to fill any store.

A touch cascade keeps busting the outer fragment

touch: true is what makes Russian doll caching correct. It's also what makes it fragile. If a child record is updated constantly (a view counter, a "last seen" timestamp, a status column updated by a background job), every one of those updates touches the parent and expires every fragment keyed on it.

class Review < ApplicationRecord
  belongs_to :product, touch: true
end

# Somewhere in a job, on every page view:
review.update!(last_viewed_at: Time.current)   # touches the product, expiring its fragments

Look for parent records whose updated_at changes far more often than their content does. Move high-churn columns to a separate table, or update them with methods that skip callbacks (such as update_column or increment!, which don't trigger touch) when skipping the parent's expiry is actually correct for your app.

You're caching the wrong kind of value

The Rails guide specifically warns against caching Active Record instances: the records can change or be deleted while the cached copy lives on. Cache IDs or plain data instead:

ids = Rails.cache.fetch("super_admin_user_ids", expires_in: 12.hours) do
  User.super_admins.pluck(:id)
end
User.where(id: ids)

This one is about correctness rather than hit rate, but it tends to show up during the same investigation.

Step 4: Protect expensive entries from stampedes

Hit rate can look healthy and still hide a spike. When a popular, expensive entry expires, every request that arrives before it's rebuilt misses at the same time and runs the expensive block in parallel. That's a cache stampede.

Rails.cache.fetch has an option for exactly this. With race_condition_ttl, the first process to find an expired entry briefly extends it and rebuilds it, while other processes keep serving the slightly stale value:

Rails.cache.fetch("homepage/top_products", expires_in: 10.minutes, race_condition_ttl: 30.seconds) do
  Product.top_sellers(limit: 12).pluck(:id, :name, :price_cents)
end

The ActiveSupport::Cache::Store docs describe the full behavior. Use it on entries that are both expensive and hot. For cheap entries, the stampede doesn't matter.

Step 5: Check that the hit is worth having

A cache hit isn't automatically faster than the work it replaces. With Redis, Memcached, or a database-backed store, every read is a network round trip. Caching something that was already cheap, like a single indexed lookup or a tiny partial, can leave the request no faster, or slower.

The honest way to check is to compare requests on the same endpoint: one where the cache hit and one where it missed. If the difference is small, the cache is adding complexity without paying for itself. If the miss path is dramatically slower, that cache is load-bearing. Protect it with sensible expiry, race_condition_ttl, and an alert if its hit rate collapses after a deploy.

This is also where caching connects to the rest of your performance work. A miss that falls back to an N+1 or a query loading thousands of records is still an N+1, just hidden behind a cache most of the time. Our posts on why Rails apps get slow and the real cost of ActiveRecord cover what to look for on that miss path.

Seeing it alongside the rest of the request

The log-line approach above works, and it's worth having even if you use nothing else. Its limit is that you're reading cache behavior in isolation, while the question you usually care about is "what did this miss cost this request?"

That's the view DeadBro gives you for Rails apps: every Rails.cache read, write, fetch and delete is recorded on the request it belongs to, shown on the request waterfall next to SQL, view rendering and external HTTP calls, with hit and miss ratios per cache store and key pattern. When an endpoint slows down after a deploy, you can open a slow request, see that its fragment cache missed and what the fallback did, and compare it with a fast request on the same endpoint. Whatever tool you use, the principle is the same: cache behavior only makes sense next to the request it was supposed to speed up.

A short caching checklist

For each cache that matters in your app:

  • You know its key family, and the key only includes what changes the output.
  • You've seen it hit, in development with bin/rails dev:cache and in production logs or metrics.
  • Production uses a shared store, not :null_store or a per-process :memory_store across Puma workers.
  • The store is sized for the entries you expect to keep.
  • Parent records using touch: true aren't being touched by high-churn updates.
  • Hot, expensive entries use race_condition_ttl.
  • You've compared a hit and a miss on the same endpoint, and the difference justifies the cache.

FAQ

How do I check if Rails caching is working?

Enable caching locally with bin/rails dev:cache, load the page twice, and look for [cache hit] and [N / M cache hits] in the development log. In production, subscribe to cache_read.active_support and cache_read_multi.active_support and count hits per request, as shown above, or use an APM that records cache operations per request.

Why is my fragment cache not working in development?

Action Controller caching is disabled in development by default. Run bin/rails dev:cache to toggle it on, then restart if your server doesn't pick it up. If it's on and still misses on every reload, check whether the cache key includes something that changes each request.

Why does my Rails cache miss so often in production?

The usual causes are keys that vary per request, a per-process :memory_store running across multiple Puma workers, a store that's too small and evicting entries, or touch: true cascades that keep expiring parent fragments.

What is a cache stampede in Rails?

It's what happens when a popular entry expires and many requests rebuild it at once. Pass race_condition_ttl to Rails.cache.fetch so one process rebuilds the entry while others briefly serve the previous value.


Caching rarely fails loudly. It fails as a page that's a bit slower than it should be, a database that's a bit busier than you expected, or a fragment that shows yesterday's price. Make the hit rate visible, and most of those problems become obvious.