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.
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 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
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 %>
<% 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.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- The outer fragment misses. On the next request the batch fragment misses and re-renders, which reads each slot fragment again.
- Three slots are reused. Slots 1, 3 and 4 never changed, so their versions still match and they are read straight from the cache.
- 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
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:
# 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.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- Without race_condition_ttl. With no guard, both readers miss, so both run the expensive block to rebuild it at once.
- 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.
- 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.
- One reader rebuilds. Reader A runs the block and will write the fresh value. No other reader recomputes while it does.
- 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
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:
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:
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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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.
# 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:
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.
The numbers as a table
| Hit ratio | Database 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
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
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:
- Open
russian_doll.rband 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 removetouch: truefrom thebelongs_toand watch the outer fragment keep serving the old timetable after a slot changes. - 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. - 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.
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.
-
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.
-
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.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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: trueon abelongs_tobubbles 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: truereads every fragment of a collection in one multi-read instead of one read per row.Rails.cache.fetchskips the block on a hit; key per-user data by the user, and userace_condition_ttlso 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_ageis two weeks unless set, with the 256 MBmax_sizea new app’scache.ymlgives it; development uses:memory_storeand test uses:null_store. - Measure the hit ratio from the
:hitpayload ofcache_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
- Caching with Rails (Ruby on Rails)
- ActiveSupport::Cache::Store (Ruby on Rails)
- ActionView::Helpers::CacheHelper (Ruby on Rails)
- rails/solid_cache (Ruby on Rails)
- Active Support Instrumentation (Ruby on Rails)
- ActionController::ConditionalGet (Ruby on Rails)
- RFC 9111: HTTP Caching (IETF)
- basecamp/thruster (Basecamp)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress