Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Ruby on Rails  Module 12 – Caching and performance

Caching with Rails and Solid Cache

Cache Rails fragments with keys that expire themselves, nest them Russian-doll style, guard low-level caches from stampedes, and run Solid Cache in production.

  • Intermediate
  • 20 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain how a fragment cache key and its stored version expire a fragment
  • Configure Russian-doll caching with touch: true and collection caching
  • Protect low-level caches from a stampede and from cross-user leaks
  • Choose a cache store and run Solid Cache in production
  • Measure the cache hit ratio from cache_read notifications

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain how a fragment cache key and its stored version expire a fragment
  • Configure Russian-doll caching with touch: true and collection caching
  • Protect low-level caches from a stampede and from cross-user leaks
  • Choose a cache store and run Solid Cache in production
  • Measure the cache hit ratio from cache_read notifications

Before you start

On this page

A teacher opens the Kaksha timetable, and Rails rebuilds the same page on every visit: it reads the batch, its four slots, and renders each row, though nothing on the page has changed since the last request. Caching stores that rendered work and serves it again, and a well-chosen key throws the copy away the moment the data behind it changes. Caching that timetable on Rails 8.1 takes fragment keys that expire themselves, Russian-doll nesting so one changed slot reuses the other three, low-level Rails.cache.fetch with stampede protection and per-user keys, Solid Cache in production, and a measured hit ratio before any tuning.

Caching is the quickest win in a slow app and the easiest to get wrong: a stale fragment shows last week’s timetable, and a shared key hands one teacher another teacher’s numbers. The repair is almost always in the key. Every key and count below is what the examples printed on Ruby 4.0.7 with Rails 8.1.4.

A fragment key stays the same while its version changes

A fragment cache: A cached copy of a piece of a rendered view, wrapped in a cache block, that is served from the cache store on the next request instead of being rendered again. wraps a piece of a view in a cache block and serves it from the store on the next request. The first render of the timetable misses every fragment and writes it; the keys name what was cached. Read the first section of this run:

Fragment keys, Russian-doll reuse and a collection multi-read Ruby · russian_doll.rb
# Fragment caching, Russian-doll nesting and collection caching, on one small Rails app.
# A batch (a Kaksha class) has many timetable slots; a slot belongs_to the batch with touch: true.
# The views cache the whole timetable and each slot, so one slot change expires only that slot and
# the outer fragment, while the other slots are reused from the cache.
require "rails"
require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "tmpdir"
require "fileutils"

views = Dir.mktmpdir("views")
FileUtils.mkdir_p("#{views}/batches")
FileUtils.mkdir_p("#{views}/slots")
File.write("#{views}/batches/show.html.erb", <<~ERB)
  <% cache @batch do %>
  Timetable for <%= @batch.name %>
  <%= render @batch.slots %>
  <% end %>
ERB
File.write("#{views}/batches/grid.html.erb", <<~ERB)
  <%= render partial: "slots/slot", collection: @batch.slots, cached: true %>
ERB
File.write("#{views}/slots/_slot.html.erb", <<~ERB)
  <% cache slot do %>
  <%= slot.subject %> in period <%= slot.period %>
  <% end %>
ERB

class App < Rails::Application
  config.load_defaults 8.1
  config.eager_load = false
  config.logger = Logger.new(nil)
  config.secret_key_base = "x" * 64
  config.hosts.clear
  config.cache_store = :memory_store
  config.action_controller.perform_caching = true
  config.action_controller.enable_fragment_cache_logging = false
end
ENV["DATABASE_URL"] = "sqlite3::memory:"
App.initialize!
ActionController::Base.prepend_view_path(views)

ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
  create_table :batches do |t|
    t.string :name
    t.timestamps
  end
  create_table :slots do |t|
    t.references :batch
    t.string :subject
    t.integer :period
    t.timestamps
  end
end

class Batch < ActiveRecord::Base
  has_many :slots, -> { order(:period) }
end
class Slot < ActiveRecord::Base
  belongs_to :batch, touch: true
end

batch = Batch.create!(name: "Class 10 A")
%w[Maths Science English History].each_with_index do |subject, i|
  batch.slots.create!(subject: subject, period: i + 1)
end

# Collect cache events so we can read the keys and whether each read hit or missed.
reads = []
writes = []
multi = []
ActiveSupport::Notifications.subscribe("cache_read.active_support") { |*, pl| reads << [pl[:key], pl[:hit]] }
ActiveSupport::Notifications.subscribe("cache_write.active_support") { |*, pl| writes << pl[:key] }
ActiveSupport::Notifications.subscribe("cache_read_multi.active_support") { |*, pl| multi << [pl[:key].size, pl[:hits].size] }

class BatchesController < ActionController::Base
  def show
    @batch = Batch.find(params[:id])
    render "batches/show"
  end

  def grid
    @batch = Batch.find(params[:id])
    render "batches/grid"
  end
end
App.routes.draw do
  get "/batches/:id" => "batches#show"
  get "/batches/:id/grid" => "batches#grid"
end

def render_show(id)
  App.call(Rack::MockRequest.env_for("/batches/#{id}"))
end
def render_grid(id)
  App.call(Rack::MockRequest.env_for("/batches/#{id}/grid"))
end

puts "=== first render: every fragment misses, then is written ==="
reads.clear; writes.clear
render_show(batch.id)
reads.each { |key, hit| puts "read  #{hit ? 'hit ' : 'miss'}  #{key}" }
writes.each { |key| puts "write       #{key}" }

puts
puts "=== second render: the outer fragment hits, so no slot is read ==="
reads.clear; writes.clear
render_show(batch.id)
reads.each { |key, hit| puts "read  #{hit ? 'hit ' : 'miss'}  #{key}" }
puts "writes: #{writes.size}"

puts
puts "=== change one slot; touch: true bubbles updated_at up to the batch ==="
batch.slots.find_by(period: 2).update!(subject: "Physics")
reads.clear; writes.clear
render_show(batch.id)
reads.each { |key, hit| puts "read  #{hit ? 'hit ' : 'miss'}  #{key}" }
writes.each { |key| puts "write       #{key}" }

puts
puts "=== collection caching: render ... collection:, cached: true is one multi-read ==="
reads.clear; writes.clear; multi.clear
render_grid(batch.id)          # warm the per-slot entries once
reads.clear; writes.clear; multi.clear
render_grid(batch.id)          # now measure the second render
multi.each { |asked, hit| puts "one multi-read asked for #{asked} keys, #{hit} hit" }
puts "single reads this render: #{reads.size}"

Output

=== first render: every fragment misses, then is written ===
read  miss  views/batches/show:b048ce15394c7abc9b95f112067e13b1/batches/1
read  miss  views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/1
read  miss  views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/2
read  miss  views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/3
read  miss  views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/4
write       views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/1
write       views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/2
write       views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/3
write       views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/4
write       views/batches/show:b048ce15394c7abc9b95f112067e13b1/batches/1

=== second render: the outer fragment hits, so no slot is read ===
read  hit   views/batches/show:b048ce15394c7abc9b95f112067e13b1/batches/1
writes: 0

=== change one slot; touch: true bubbles updated_at up to the batch ===
read  miss  views/batches/show:b048ce15394c7abc9b95f112067e13b1/batches/1
read  hit   views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/1
read  miss  views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/2
read  hit   views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/3
read  hit   views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/4
write       views/slots/_slot:4d356e1f3cd8eb21e819eec779578b4e/slots/2
write       views/batches/show:b048ce15394c7abc9b95f112067e13b1/batches/1

=== collection caching: render ... collection:, cached: true is one multi-read ===
one multi-read asked for 4 keys, 4 hit
single reads this render: 0

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby russian_doll.rb

The outer fragment is written under views/batches/show:b048ce15.../batches/1. That key has three parts: the template path, a template digest: A hash of a template's contents and the templates it renders, included in a fragment's cache key, so that editing the markup gives the fragment a new key. (the hex string b048ce15...: with the 8.1 defaults, the first 32 characters of a SHA-256 hash of the template’s source and of the templates it renders), and the record’s cache key: The string that names a cache entry; for a fragment it holds the template path, the template digest and the record, as in views/batches/show:<digest>/batches/1., batches/1. Change the template’s HTML and the digest changes, so the fragment gets a new key; the old entry is simply never asked for again.

The record’s id is in the key, but its updated_at is not. Instead a cache version: A value derived from a record's updated_at and stored with its cached fragment, so that a touched record makes the entry stale while its key stays the same., derived from updated_at, is stored with the entry. When the batch is touched, the version stored with the fragment no longer matches the record’s current version, so the next read misses and rewrites the same key. That is why the second render reads one key and hits, and why an update later rewrites batches/1 rather than leaving a dead entry behind. The caching guide calls this separating the key from the version; it keeps the cache small because an updated record overwrites its own entry instead of accumulating one entry per version.

The key carries no timestamp

You may read that a fragment “keys on cache_key_with_version”, which would put the timestamp in the key. In Rails 8.1 the key ends with the record’s cache key, such as slots/2, and the version is stored beside the entry, which is what the run above prints: the key for a slot is identical before and after its update.

Checkpoint

Check: The fragment key and its version

  1. Question 1 of 2 A fragment key reads views/batches/show:b048ce15.../batches/1. Which parts does it hold? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • A digest of the template contents
    • The record's cache key, such as batches/1
    • The template path

    The key names the template path, a digest of the template, and the record. The record's version comes from updated_at but is stored with the entry, so the key does not change when the record is touched.

  2. Question 2 of 2 A batch is touched, so its fragment is stale. On the next render, what happens to its cache key?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The same key is read, misses because the stored version no longer matches, and is rewritten

    Separating the key from the version lets Rails reuse the key: a touched record misses on its stable key and overwrites the entry, so the cache does not grow one entry per version.

Russian-doll caching reuses the inner fragments

The timetable nests fragments: the batch wraps a cache block, and inside it each slot has its own. This is Russian-doll caching: Nesting cached fragments inside other cached fragments, so that when one inner fragment changes the unchanged inner fragments are reused while the outer one is rebuilt., and its payoff shows when one slot changes. A plain belongs_to :batch would expire the changed slot but leave the outer fragment serving the old timetable, because the batch’s updated_at never moved. Adding touch: true fixes that: every save of a slot also touches the batch.

app/views/batches/show.html.erb and the slot partial ERB · snippets/timetable.html.erb
<%# app/views/batches/show.html.erb %>
<% cache @batch do %>
  <h1>Timetable for <%= @batch.name %></h1>
  <%= render @batch.slots %>
<% end %>

<%# app/views/slots/_slot.html.erb %>
<% cache slot do %>
  <%= slot.subject %> in period <%= slot.period %>
<% end %>

<%# A read-only list of the same slots fetched in one multi-read %>
<%= render partial: "slots/slot", collection: @batch.slots, cached: true %>

Follow what the third section of the run does when slot 2 changes. The slot’s own fragment expires, touch: true bumps the batch’s updated_at, and the outer fragment’s version no longer matches, so it misses and re-renders. But re-rendering the outer fragment reads each slot’s fragment again, and slots 1, 3 and 4 are unchanged, so they hit: the run shows one outer miss, three slot hits and one slot miss. Only the changed slot and the wrapper are rebuilt.

Interactive · step by step One slot change rippling through the fragments Step through what happens to the nested fragments when one slot of the timetable changes.
One slot change rippling through the fragments, step 6 of 6. Showing Timetable (now stale), Slot 1, Slot 2 (now stale), Slot 3, Slot 4. In focus: Slot 2.Timetablenow staleSlot 1MathsSlot 2now staleSlot 3EnglishSlot 4History

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Four slots, all cached. The timetable fragment on the left wraps the four slot fragments on the right. After the first render every one of them is in the cache.
  2. A teacher edits slot 2. Slot 2 changes from Science to Physics. Its own version no longer matches the entry, so that fragment is now stale.
  3. touch: true wakes the batch. Because the slot belongs_to the batch with touch: true, saving the slot also updates the batch, so the outer fragment is stale too.
  4. The outer fragment misses. On the next request the batch fragment misses and re-renders, which reads each slot fragment again.
  5. Three slots are reused. Slots 1, 3 and 4 never changed, so their versions still match and they are read straight from the cache.
  6. Only slot 2 is rebuilt. Slot 2 and the outer wrapper are the only fragments rendered again; the run shows one outer miss, one slot miss and three slot hits.

A read-only list of the same slots can skip the one-at-a-time reads entirely with collection caching: Rendering a partial for a collection with cached: true, so that Rails fetches the cached fragments for every item in one multi-read instead of one read per item.: render partial: "slots/slot", collection: @batch.slots, cached: true fetches every slot’s fragment in one multi-read. The last section of the run shows one multi-read asking for four keys and getting four hits, with zero single reads, which is one round trip to the store instead of four.

Key idea

touch: true is what makes nesting pay: a changed child expires its own fragment and, through the touch, the outer one, while every sibling that did not change is read straight from the cache.

Checkpoint

Check: Russian-doll and collection caching

  1. Question 1 of 2 With belongs_to :batch, touch: true, slot 2 of four changes. Which fragments are rebuilt on the next render?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The outer batch fragment and slot 2; slots 1, 3 and 4 are read from the cache

    The run shows one outer miss, one slot miss (slot 2) and three slot hits. Russian-doll caching rebuilds the changed fragment and the wrapper and reuses the rest.

  2. Question 2 of 2 What does render partial: "slots/slot", collection: @batch.slots, cached: true do that a plain each loop does not?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It reads every slot's fragment in one multi-read instead of one read per slot

    Collection caching fetches the cached entries for the whole collection at once, so a list of four slots is one multi-read rather than four single reads.

Low-level caching without a stampede or a leak

Not everything worth caching is a view. low-level caching: Storing any serializable value under a key you choose with Rails.cache methods such as fetch, read and write, rather than caching a rendered view. stores any serializable value under a key you choose, with Rails.cache.fetch: on a hit it returns the stored value and skips the block, on a miss it runs the block, writes the result and returns it. The first two sections of this run show a hit skipping the block and an expired entry running it again:

fetch hits, expiry, per-user keys and stampede protection Ruby · low_level.rb
# Low-level caching with Rails.cache.fetch: a cache hit skips the block, an expired entry runs it
# again, a per-user key keeps one reader's data out of another's, and race_condition_ttl stops a
# stampede by letting one reader rebuild while the others read the just-expired value.
require "rails"
require "active_support"
require "active_support/cache"
require "active_support/core_ext/integer/time"
require "active_support/core_ext/numeric/time"
require "active_support/testing/time_helpers"

Rails.cache = ActiveSupport::Cache::MemoryStore.new
clock = Object.new.extend(ActiveSupport::Testing::TimeHelpers)

puts "=== a cache hit skips the block ==="
runs = 0
first  = Rails.cache.fetch("greeting") { runs += 1; "Namaste" }
second = Rails.cache.fetch("greeting") { runs += 1; "Namaste" }
puts "value #{first.inspect}, same on the second read: #{second == first}"
puts "the block ran #{runs} time(s) over two reads"

puts
puts "=== an expired entry runs the block again ==="
runs = 0
Rails.cache.write("report", "Monday figures", expires_in: 5.minutes)
warm = Rails.cache.fetch("report", expires_in: 5.minutes) { runs += 1; "fresh figures" }
clock.travel 6.minutes do
  cold = Rails.cache.fetch("report", expires_in: 5.minutes) { runs += 1; "fresh figures" }
  puts "within 5 minutes: #{warm.inspect} (hit)"
  puts "after 6 minutes:  #{cold.inspect} (rebuilt)"
  puts "the block ran #{runs} time(s)"
end

puts
puts "=== a shared key leaks one reader's data to another ==="
unread = { "Asha" => 3, "Ravi" => 7 }
Rails.cache.clear
shared_a = Rails.cache.fetch("unread_count") { unread["Asha"] }
shared_b = Rails.cache.fetch("unread_count") { unread["Ravi"] }
puts "shared key:     Asha sees #{shared_a}, Ravi sees #{shared_b}"
Rails.cache.clear
keyed_a = Rails.cache.fetch(["unread_count", "Asha"]) { unread["Asha"] }
keyed_b = Rails.cache.fetch(["unread_count", "Ravi"]) { unread["Ravi"] }
puts "keyed by user:  Asha sees #{keyed_a}, Ravi sees #{keyed_b}"

# A controlled two-reader race over one just-expired key. A Queue makes the order exact: the first
# reader enters its block (after fetch has decided what to do about the expired entry) and signals,
# then the second reader reads. Nothing in the output depends on timing.
def race(key, ttl)
  Rails.cache.clear
  Rails.cache.write(key, "Monday figures", expires_in: 0.01)
  sleep 0.05 # let the entry expire; expiry is by clock, not by load
  runs = 0
  mutex = Mutex.new
  inside = Queue.new
  opts = { expires_in: 5.minutes }
  opts[:race_condition_ttl] = ttl if ttl
  rebuilder = Thread.new do
    Rails.cache.fetch(key, **opts) do
      mutex.synchronize { runs += 1 }
      inside << :ready
      sleep 0.2 # hold the block so the second reader reads during the rebuild
      "Tuesday figures"
    end
  end
  inside.pop # wait until the first reader is inside its block
  second = Rails.cache.fetch(key, **opts) { mutex.synchronize { runs += 1 }; "Tuesday figures" }
  rebuilder.join
  [runs, second]
end

puts
puts "=== two readers hit one just-expired key at once ==="
no_ttl = race("timetable", nil)
with_ttl = race("timetable", 10.seconds)
puts "without race_condition_ttl: the block ran #{no_ttl[0]} times; the 2nd reader got #{no_ttl[1].inspect}"
puts "with race_condition_ttl:    the block ran #{with_ttl[0]} time; the 2nd reader got #{with_ttl[1].inspect}"

Output

=== a cache hit skips the block ===
value "Namaste", same on the second read: true
the block ran 1 time(s) over two reads

=== an expired entry runs the block again ===
within 5 minutes: "Monday figures" (hit)
after 6 minutes:  "fresh figures" (rebuilt)
the block ran 1 time(s)

=== a shared key leaks one reader's data to another ===
shared key:     Asha sees 3, Ravi sees 3
keyed by user:  Asha sees 3, Ravi sees 7

=== two readers hit one just-expired key at once ===
without race_condition_ttl: the block ran 2 times; the 2nd reader got "Tuesday figures"
with race_condition_ttl:    the block ran 1 time; the 2nd reader got "Monday figures"

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby low_level.rb

Two traps live here. The first is the key. Cache a teacher’s unread count under the bare key "unread_count" and the second teacher’s read is a hit that returns the first teacher’s number: the run prints Asha and Ravi both seeing 3. Put the owner in the key, ["unread_count", teacher.id], and each sees their own (3 and 7). A key for per-user or per-tenant data always names the user or the tenant.

The second trap is a cache stampede: The surge of work when a popular cache entry expires and many requests miss at once, each running the expensive block, which can overwhelm the database behind the cache.. When a popular entry expires, every request that arrives in the same instant misses and runs the expensive block at once, and the rebuilds can overwhelm the database the cache was protecting. Rails.cache.fetch(key, expires_in:, race_condition_ttl:) guards against it: on a just-expired entry it briefly puts the stale value back and lets one reader rebuild while the others read that stale value. The last section times two readers hitting one just-expired key: without race_condition_ttl the block runs twice and both get the fresh value; with it, the block runs once and the second reader gets the previous value for a moment.

Interactive · step by step Two readers hit one just-expired entry Step through fetch on a popular entry that has just expired, first without and then with race_condition_ttl.
Two readers hit one just-expired entry, step 6 of 6. Showing Cache entry (stale kept), Reader A (writing), Reader B (reads stale). In focus: Reader B.Cache entrystale keptReader AwritingReader Breads stale

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. One entry, two readers. The timetable value is cached under one key. It has just expired, and two readers ask for it at the same moment.
  2. Without race_condition_ttl. With no guard, both readers miss, so both run the expensive block to rebuild it at once.
  3. Both rebuild the same value. Every reader that arrives in the window recomputes the same value; a crowd of readers becomes a crowd of rebuilds that can swamp the database.
  4. With race_condition_ttl. Now both readers call fetch with race_condition_ttl. Reader A finds the entry expired first, writes the stale value back for that window (10 seconds in the run) and starts the rebuild.
  5. One reader rebuilds. Reader A runs the block and will write the fresh value. No other reader recomputes while it does.
  6. The others read the stale value. Reader B reads the stale value for that moment instead of rebuilding, so the block runs once, not twice.

A short lifetime is not stampede protection

Lowering expires_in makes the entry expire more often, which causes more stampedes, not fewer. The guard is race_condition_ttl, which bounds the rebuild to one reader; it does not shorten or lengthen how long the value is cached.

Checkpoint

Check: Low-level fetch, keys and stampedes

  1. Question 1 of 2 Two teachers' unread counts are cached under the bare key "unread_count". What happens, and what fixes it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The second teacher's read hits and returns the first teacher's count; put the teacher in the key

    A shared key makes one teacher's value serve another. Per-user or per-tenant data must name the user or tenant in the key.

  2. Question 2 of 2 What does race_condition_ttl do when a popular entry has just expired and many readers arrive at once?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It lets one reader rebuild while the others briefly read the stale value, so the block runs once

    On a just-expired entry race_condition_ttl puts the stale value back for a short window and lets one reader rebuild, so a crowd of readers causes one recompute, not a stampede.

Solid Cache keeps a large cache in the database

Solid Cache: Rails 8's database-backed cache store, which keeps entries in one table of their own database and expires the oldest ones as it writes. is the default cache store: The backend that holds cache entries, set with config.cache_store, such as memory_store, file_store, solid_cache_store, redis_cache_store or null_store. for new Rails 8 apps: a database-backed store that keeps entries in a table instead of in memory. Because disk is cheap next to RAM, it holds a far larger cache than a memory-only Redis or Memcached, and it needs no separate service, since it rides on a database you already run. A new app points production at its own cache database:

config/database.yml (the cache database) YAML · snippets/database.yml
default: &default
  adapter: sqlite3
  max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

production:
  primary:
    <<: *default
    database: storage/production.sqlite3
  cache:
    <<: *default
    database: storage/production_cache.sqlite3
    migrations_paths: db/cache_migrate

Its behaviour is configured in config/cache.yml. The generated file sets a max_size and a namespace and leaves max_age commented out, so the gem default applies:

config/cache.yml YAML · snippets/cache.yml
default: &default
  store_options:
    # Cap age of oldest cache entry to fulfill retention policies
    # max_age: <%= 60.days.to_i %>
    max_size: <%= 256.megabytes %>
    namespace: <%= Rails.env %>

development:
  <<: *default

test:
  <<: *default

production:
  database: cache
  <<: *default

The cache_hit_rate.rb run below reads those settings back from a booted :solid_cache_store: max_age is 14 days (the 2.weeks default, because cache.yml leaves it out), max_size is 256 MB, max_entries is nil, and the expiry batch size is 100. Solid Cache is a FIFO store: it evicts the oldest entries first, not the least recently used, which the Solid Cache README notes is simpler to manage and is offset by keeping entries longer. Expiry runs on writes: once writes reach half the batch size, a background task checks whether the cache is over max_size and, if it is, deletes a batch of the oldest entries; otherwise it deletes up to a batch of entries older than max_age.

Each environment picks its own store. Production uses config.cache_store = :solid_cache_store; development uses :memory_store (fast, but each process keeps its own copy, so it is not shared across a cluster) with config.action_controller.perform_caching off until you run bin/rails dev:cache; and test uses :null_store, which stores nothing so no cached value leaks between tests. The store you pick is a trade between being shared, surviving a restart, needing a service and holding a large cache:

Interactive · answer and compare Which cache store fits your app? Answer three questions about your app, and the table marks the stores that fit.
Does your app run on more than one server?
Must the cache survive a restart or a deploy?
Can you run a separate cache service?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Six cache stores against being shared, surviving a restart, needing a service and holding a large cache.
Store Shared by processes Survives a restart No extra service Holds a large cache
Memory store Keeps entries in: this process Poor each process keeps its own copy Poor entries are lost on a restart Good runs inside the app process Poor 32 MB by default, held in RAM
File store Keeps entries in: files on disk Mixed shared only by processes on one host Good entries live on disk Good no service, just a directory Mixed grows on disk until you prune it
Solid Cache Keeps entries in: a database table Good one table read by the whole fleet Good entries live in the database Good uses a database you already run Good disk-backed, 256 MB and well beyond
Redis store Keeps entries in: a Redis service Good one service read by every process Mixed durable only if Redis persists to disk Poor needs a Redis server to run Mixed held in RAM, evicts under pressure
Memcached store Keeps entries in: a memcached cluster Good one cluster read by every process Poor memory only, lost on a restart Poor needs a memcached cluster to run Mixed held in RAM, evicts under pressure
Null store Keeps entries in: nothing at all Poor stores nothing to share Poor stores nothing to keep Good no service and no storage Poor holds nothing: it is the test store
How the answers decide
  • “Does your app run on more than one server?” Yes, several: rules out Memory store, File store and Null store. A per-process copy, a per-host file or a store that keeps nothing is not shared across several servers.
  • “Must the cache survive a restart or a deploy?” Yes, it must: rules out Memory store, Memcached store and Null store. An in-memory store, or one that keeps nothing, has no entries left after a restart or a deploy.
  • “Can you run a separate cache service?” Prefer not to: rules out Redis store and Memcached store. Both need a separate service that you run, monitor and keep alive.
  • “Does your app run on more than one server?” No, just one: adds a caveat to Memory store. Even on one server the memory store keeps a separate copy in each Puma worker, so one worker misses what another cached.

Checkpoint

Check: Solid Cache and cache stores

  1. Question 1 of 2 How does Solid Cache decide which entries to evict when it needs to shrink?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: First in, first out, oldest entries first

    Solid Cache is a FIFO store: it deletes the oldest entries first rather than tracking when each was read, which keeps management cheap at the cost of LRU's efficiency.

  2. Question 2 of 2 Which cache store keeps a separate copy per process, so a cluster of Puma workers does not share it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: :memory_store

    memory_store is per process, which is why a new app uses it only in development; production uses a shared store such as Solid Cache.

Measuring the hit ratio before you tune it

Before tuning a cache, measure it, because the number that matters is the share of reads the cache answers itself. The cache_read.active_support notification carries a :hit boolean in its payload, so a subscriber can count hits and misses without guessing. In cache_hit_rate.rb, ten teachers each open their dashboard ten times: the first view of each teacher misses and the nine that follow hit, giving 10 misses and 90 hits over 100 reads, a cache hit ratio: The share of cache reads answered from the cache: hits divided by hits plus misses. The misses are the reads that still reach the layer behind the cache. of 90 %. The block ran 10 times, and Solid Cache holds 10 entries.

Counting hits and misses over 100 dashboard views Ruby · cache_hit_rate.rb
# Measuring the cache hit ratio on Solid Cache. Ten teachers each open their dashboard ten times
# (100 page views). The unread-notice count is cached per teacher with Rails.cache.fetch, so the
# first view of each teacher misses and the nine after it hit: 10 misses and 90 hits, a 90% hit
# ratio. The cache_read.active_support event carries :hit, so the ratio is counted, not guessed.
require "rails"
require "active_record/railtie"
require "active_support/core_ext/numeric/bytes"
require "solid_cache"

class App < Rails::Application
  config.load_defaults 8.1
  config.eager_load = false
  config.logger = Logger.new(nil)
  config.secret_key_base = "x" * 64
  config.hosts.clear
  # The same shape as a generated config/cache.yml: a size cap and a namespace, and no max_age,
  # so Solid Cache keeps its own default.
  config.cache_store = :solid_cache_store, { max_size: 256.megabytes, namespace: "production" }
end
ENV["DATABASE_URL"] = "sqlite3::memory:"
App.initialize!

ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
  create_table :solid_cache_entries do |t|
    t.binary :key, limit: 1024, null: false
    t.binary :value, limit: 536870912, null: false
    t.datetime :created_at, null: false
    t.integer :key_hash, limit: 8, null: false
    t.integer :byte_size, limit: 4, null: false
    t.index :byte_size
    t.index [:key_hash, :byte_size]
    t.index :key_hash, unique: true
  end
end

reads = 0
hits = 0
block_runs = 0
ActiveSupport::Notifications.subscribe("cache_read.active_support") do |*, payload|
  reads += 1
  hits += 1 if payload[:hit]
end

teacher_ids = (1..10).to_a
10.times do
  teacher_ids.each do |id|
    Rails.cache.fetch(["teacher", id, "unread_count"]) do
      block_runs += 1
      id * 2 # stands in for an expensive COUNT query
    end
  end
end

misses = reads - hits
puts "page views (reads): #{reads}"
puts "hits: #{hits}"
puts "misses: #{misses}"
puts "the block ran: #{block_runs} times"
puts "hit ratio: #{(hits * 100.0 / reads).round(1)}%"
puts "entries written to Solid Cache: #{SolidCache::Entry.count}"

puts
puts "the store and its eviction settings:"
puts "Rails.cache.class: #{Rails.cache.class}"
puts "max_age (days): #{Rails.cache.max_age / 86400}"
puts "max_size (MB): #{Rails.cache.max_size / 1.megabyte}"
puts "max_entries: #{Rails.cache.max_entries.inspect}"
puts "expiry_batch_size: #{Rails.cache.expiry_batch_size}"

Output

page views (reads): 100
hits: 90
misses: 10
the block ran: 10 times
hit ratio: 90.0%
entries written to Solid Cache: 10

the store and its eviction settings:
Rails.cache.class: SolidCache::Store
max_age (days): 14
max_size (MB): 256
max_entries: nil
expiry_batch_size: 100

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby cache_hit_rate.rb

A hit ratio sets the load that reaches the database: the misses are the reads the database still serves. Start this model at the measured 90 % and 2,000 reads a second against a database that can serve 500, then lower the ratio and watch the database pass its limit:

Interactive · change it and see Database load as the hit ratio falls Change the reads a second, the share of them the cache answers and how many reads the database can take. Every miss goes to the database.
Database load by hit ratio: database reads a second against hit ratio (%).02040608010005001k1.5k2k2.5khit ratio (%)database reads a seconddatabase loadlimit 500now 90 %

At 90 % hits, 200 of 2,000 reads a second reach the database, within its limit of 500. Every extra nine of hit ratio cuts the database load tenfold.

Reads that reach the database 200 a second
Reads the cache answers 1,800 a second
Hit ratio needed to stay under the limit at least 75 %
The numbers as a table
Database load at a few hit ratios
Hit ratioDatabase reads a second
50 %1,000
80 %400
90 %200
95 %100
99 %20
99.9 %2

At 90 % the database serves 200 reads a second, well inside 500. Below 75 % it is over its limit, and at 0 %, an empty cache after a flush or a deploy, it receives all 2,000. Measure the warm ratio, but size the database for the cold one.

Checkpoint

Check: Measuring the hit ratio

  1. Question 1 of 2 Which notification tells you whether each cache read was a hit?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: cache_read.active_support, whose payload carries a :hit boolean

    The cache_read.active_support event fires on every read with a :hit boolean, so a subscriber can count the ratio instead of guessing it.

  2. Question 2 of 2 A cache takes 2,000 reads a second at a 90% hit ratio. How many reads a second reach the database?

    Type a number.

    A hint

    The database serves the misses, which are the reads times one minus the hit ratio.

    Show the answer: Question 2 of 2

    Answer: 200 a second

    The misses are 10% of 2,000, which is 200 a second. That is the warm load; after a flush the ratio drops towards 0% and the database would receive all 2,000.

HTTP caching in front of the app

The caches so far sit inside the app. A different layer sits in front of it and can skip the response entirely. A conditional GET: A request that carries a validator, an ETag or a time, from an earlier response, so the server can answer 304 with no body when the page has not changed since. More in “Filters, errors and HTTP features” lets the browser keep its copy: fresh_when @batch sets an ETag and Last-Modified from the record, and when the browser sends them back unchanged, Rails answers 304 Not Modified with no body (see ConditionalGet). The lesson on filters, errors and HTTP features covers stale?, fresh_when, 304 and no_store in full; this is where fragment caching and HTTP caching meet.

Two more things belong here. First, the directives themselves: expires_in with public: true lets a shared proxy keep a response, while no_store sends Cache-Control: no-store, which keeps a personal page, such as a teacher’s dashboard, out of every cache. RFC 9111 defines these directives, and a handful decide where a response may live:

Directive What it allows Use it for
public shared proxies and browsers may store it a page every visitor may see
private only the browser may store it a personal page a proxy must not keep
no-store no cache keeps any copy data that must never be reused
max-age a cache may reuse it for that many seconds how long a storable response stays fresh

Second, Thruster, which runs in front of Puma in a new Rails app, keeps an HTTP cache of public assets (64 MB unless CACHE_SIZE says otherwise), so after the first request a fingerprinted CSS or JavaScript file is answered from that cache without reaching a Puma thread.

Checkpoint

Check: HTTP caching in front of the app

  1. Question 1 of 2 What does fresh_when @batch let the server answer when the browser sends back an unchanged ETag?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 304 Not Modified, with no response body

    fresh_when sets an ETag and Last-Modified from the record; when the browser returns a matching validator Rails answers 304 Not Modified and sends no body.

  2. Question 2 of 2 Which Cache-Control directive keeps a teacher's personal dashboard out of every cache, shared or private?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: no-store

    A personal page sends no-store so no cache keeps it; public and max-age both allow a copy, which risks serving one user's page to another.

Practise: cache the timetable without serving stale or shared data

Copy the recorded scripts and change them, so the keys and counts come from your own runs:

  1. Open russian_doll.rb and add a fifth slot after the first render. Re-render and confirm the outer fragment misses (its slot list changed) while the first four slots stay hits. Then remove touch: true from the belongs_to and watch the outer fragment keep serving the old timetable after a slot changes.
  2. In low_level.rb, change the shared-key section to cache a teacher’s timetable under "timetable" and show the leak; then fix it with a per-teacher key and confirm each teacher sees their own.
  3. In cache_hit_rate.rb, make three of the ten teachers open their dashboard twenty times instead of ten and recompute the hit ratio by hand, then check it against the printed number.

Then answer the questions below on all six ideas, from the fragment key to HTTP caching; the ones you have missed most come first:

Practice that adapts to you

Practise this lesson

12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 What makes a fragment's template digest change, so the fragment gets a new key?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: Editing the template's HTML (or a template it renders)

    The digest fingerprints the template, so changing the markup makes a new key and the old entry is never asked for again.

  2. Question 2 of 12 Why does updating a cached record not leave a growing pile of dead cache entries?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: The key is stable, so the update overwrites the same entry rather than adding a new one

    Because the version is stored beside the entry and the key is stable, an update misses and overwrites that one key, so the cache does not accumulate one entry per version.

  3. Question 3 of 12 What does touch: true on a belongs_to do?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Every save of the child also updates the parent's updated_at

    touch: true updates the parent's updated_at on every child save, which bumps the version of the parent's fragment so the outer fragment expires when a child changes.

  4. Question 4 of 12 A timetable has four slot fragments inside the batch fragment. One slot changes, with touch: true set. How many of the four slot fragments are re-rendered on the next request?

    Type a number.

    A hint

    Only the changed slot's version moved; the others still match.

    Show the answer: Question 4 of 12

    Answer: 1 fragments

    Just the changed slot is re-rendered; the other three hit the cache. The outer batch fragment is also rebuilt, but that is the wrapper, not one of the four slots.

  5. Question 5 of 12 On a cache hit, does Rails.cache.fetch(key) { expensive } run the block?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: No, it returns the stored value and skips the block

    fetch runs the block only on a miss; on a hit it returns the cached value, which is the whole point of caching an expensive computation.

  6. Question 6 of 12 A dashboard caches each teacher's figures under the one key "dashboard". What is the visible symptom?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: One teacher sees another teacher's figures

    A key that does not name the teacher makes one entry shared by all, so the first teacher's figures are served to everyone until the entry expires.

  7. Question 7 of 12 Where does Solid Cache, the default store for new Rails 8 apps, keep its entries?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: In a database table

    Solid Cache keeps entries in a database table, so the cache survives restarts, is shared across processes, and can be large without a dedicated cache service.

  8. Question 8 of 12 An app runs several Puma workers, the cache must survive a deploy, and the team does not want to run a new service. Which store fits?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: :solid_cache_store

    Shared, durable and no new service is exactly Solid Cache's niche, which is why it is the default production store in Rails 8.

  9. Question 9 of 12 Over 100 page views a cache records 10 misses and 90 hits. What is the hit ratio, as a percentage?

    Type a number.

    A hint

    The hit ratio is hits divided by total reads.

    Show the answer: Question 9 of 12

    Answer: 90 %

    90 hits out of 100 reads is a 90% hit ratio, the number the cache_hit_rate.rb run prints from the :hit payload of cache_read.active_support.

  10. Question 10 of 12 How can you count cache hits and misses exactly instead of estimating them?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Subscribe to cache_read.active_support and read the :hit flag of each event

    Active Support instrumentation fires cache_read.active_support on every read with a :hit boolean, so a subscriber counts the ratio from real events.

  11. Question 11 of 12 What is a conditional GET?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: A request that carries a validator from an earlier response so the server can answer 304 if nothing changed

    A conditional GET sends a validator (an ETag or a last-modified time) from a previous response, so the server can reply 304 Not Modified with no body when the resource is unchanged.

  12. Question 12 of 12 What does Thruster, which runs in front of Puma in a new Rails app, do for caching?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: It keeps an HTTP cache of public assets, so repeat requests for them are answered without Ruby

    Thruster sits in front of Puma and keeps an HTTP cache of public assets, so after the first request a fingerprinted file is answered from that cache and the repeat requests never use a Ruby thread.

Interview questions

What does touch: true on a belongs_to do for nested fragment caches?

With touch: true on a belongs_to, every save of the child also updates the parent’s updated_at. In Russian-doll caching the outer fragment is keyed by the parent, and the parent’s updated_at feeds the version stored with that fragment. So when a slot changes, touch: true bumps the batch’s updated_at, the outer timetable fragment’s version no longer matches, and its next read misses and rebuilds. Without touch: true the slot’s own fragment would expire but the outer fragment would keep serving the old timetable. The rebuild is cheap, because the three slots that did not change are still fresh and are read straight from the cache; only the changed slot and the outer wrapper are rendered again.

Why does the test environment use the :null_store cache?

The generated config/environments/test.rb sets config.cache_store = :null_store, a store that keeps nothing: every write is dropped and every read misses. Tests must be independent and repeatable, so a value cached by one test must never change the result of another; with :null_store there is no shared state to leak between tests, and no stale entry survives from a previous run. It also means a test that exercises caching sees a miss every time, so you assert on the code path rather than on an entry a different test happened to write. When you do want to test caching behaviour, swap in a :memory_store inside that one test. Development uses :memory_store, and production uses :solid_cache_store.

Two harder questions from the module bank start where these leave off: one on a cross-user cache leak found in production, the other on a cache that expires under a crowd. Decide your first move for each, then open its first step.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    After a caching change shipped, one teacher reports seeing another teacher's dashboard figures. Walk through how you would confirm the leak, find its cause, and fix it so it cannot return.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    At 9:00 a cached class timetable expires and about 2,000 students open it in the same minute. The database spikes and some requests time out. Explain the cause and how you would prevent it.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What goes into a fragment cache key, and where does the record's version go?

    The key names the template path, a digest of the template and the record; the record's version is stored with the entry. The key stays the same, so an update rewrites that entry instead of leaving a dead one behind.

  2. How does touch: true on a belongs_to make Russian-doll caching pay?

    Each save of the child also updates the parent's updated_at, so the outer fragment no longer matches and rebuilds. Only the changed inner fragment and its wrapper are rendered again; every unchanged sibling is read from the cache.

  3. What does cached: true add to render with collection:?

    It reads every fragment of the collection in one multi-read, instead of one cache read per row.

  4. How do you use Rails.cache.fetch without a stampede or a leak of one user's data to another?

    Put the user in the key of anything per-user. Set race_condition_ttl so that, when an entry expires, one reader rebuilds it while the others keep reading the old value for a moment.

  5. Which cache store does each environment of a new app use, and what are Solid Cache's limits?

    Solid Cache in production: a database-backed store that drops its oldest entries first, keeps entries two weeks unless max_age says otherwise, and gets a max_size of 256 MB from the generated cache.yml. Development uses memory_store and test uses null_store.

  6. How do you measure the cache hit ratio, and why size the database for the cold cache?

    Count the hit field in the payload of cache_read.active_support events. The misses decide the database load, and after a flush or a deploy every read misses, so the database has to carry the empty cache, not the warm one.

Key takeaways

  • A fragment cache key names the template path, a digest of the template and the record, while the record’s version is stored with the entry, so the key stays stable and an update rewrites it rather than leaving a dead entry.
  • touch: true on a belongs_to bubbles a child’s change up to the parent, so Russian-doll nesting rebuilds only the changed fragment and the wrapper and reuses every unchanged sibling.
  • render ... collection:, cached: true reads every fragment of a collection in one multi-read instead of one read per row.
  • Rails.cache.fetch skips the block on a hit; key per-user data by the user, and use race_condition_ttl so one reader rebuilds an expired entry while the rest read the stale value, which prevents a stampede.
  • Solid Cache is the default production store, a FIFO database-backed cache whose max_age is two weeks unless set, with the 256 MB max_size a new app’s cache.yml gives it; development uses :memory_store and test uses :null_store.
  • Measure the hit ratio from the :hit payload of cache_read.active_support; the misses set the database load, so size the database for the empty cache, not the warm one.

Terms in this lesson

cache hit ratio
The share of cache reads answered from the cache: hits divided by hits plus misses. The misses are the reads that still reach the layer behind the cache.
cache key
The string that names a cache entry; for a fragment it holds the template path, the template digest and the record, as in views/batches/show:<digest>/batches/1.
cache stampede
The surge of work when a popular cache entry expires and many requests miss at once, each running the expensive block, which can overwhelm the database behind the cache.
cache store
The backend that holds cache entries, set with config.cache_store, such as memory_store, file_store, solid_cache_store, redis_cache_store or null_store.
cache version
A value derived from a record's updated_at and stored with its cached fragment, so that a touched record makes the entry stale while its key stays the same.
collection caching
Rendering a partial for a collection with cached: true, so that Rails fetches the cached fragments for every item in one multi-read instead of one read per item.
fragment cache
A cached copy of a piece of a rendered view, wrapped in a cache block, that is served from the cache store on the next request instead of being rendered again.
low-level caching
Storing any serializable value under a key you choose with Rails.cache methods such as fetch, read and write, rather than caching a rendered view.
Russian-doll caching
Nesting cached fragments inside other cached fragments, so that when one inner fragment changes the unchanged inner fragments are reused while the outer one is rebuilt.
template digest
A hash of a template's contents and the templates it renders, included in a fragment's cache key, so that editing the markup gives the fragment a new key.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.