Caching: The Key You Never Maintain
A Foundations tutorial on Rails caching at two altitudes — fragment/Russian-doll caching in the view and HTTP caching in the controller — taught from first principles and grounded in Campfire's real code. The spine: the cache key is always DERIVED from the data (updated_at, an ETag, a signed token), never a hand-maintained version integer. touch: declares the dependency graph once on the association; stale?/fresh_when let an expensive action body never run on a hit. Ends with patterns to steal and points to P2 (Derive, Don't Store) and P4 (Convention Is Leverage).
The pain: you cached it, and now it lies
You're rendering a room. Two hundred messages, each with an avatar, a timestamp, maybe a row of emoji boosts. On every page load and every live append you re-render all of it — the same markup, the same Markdown-to-HTML pass, the same boost rows — for messages that have not changed since the last request. It's slow, so you reach for caching.
And here is the version you'd vibe-code first, the one that feels obviously correct:
# in the view
<% @messages.each do |message| %>
<% Rails.cache.fetch("message-#{message.id}-v3") do %>
<%= render "messages/message", message: message %>
<% end %>
<% end %>
# in the model, to keep the cache fresh
class Boost < ApplicationRecord
belongs_to :message
after_create { message.update(updated_at: Time.current) } # bump so the cache busts
# ...and you forget the same line on destroy
end
Look at what you just signed up to maintain by hand. The cache key has a literal v3 in it — a version integer you type, and bump to v4 the day the partial's markup changes, across however many files reference it. The freshness is wired with an after_create that re-touches the parent — which you wrote on Boost but forgot on the destroy path, so deleting a reaction leaves the message's cached HTML stale and the reaction lingers on screen until something else happens to bust it. And you debug that for an hour, because nothing errors. The cache doesn't crash. It just quietly serves yesterday's HTML.
That is the whole problem with caching, stated honestly: a cache key is a second source of truth about "has this changed?", and a second source of truth eventually disagrees with the first. Every bug above is the gap between the data and the key you maintained by hand. The Campfire move is to never maintain it by hand — to derive the key from the data so that "if the content changes, the key changes" is true by construction, not by vigilance. That single idea runs through both altitudes of caching we'll cover: the fragment cache in the view, and the HTTP cache in the controller.
Part 1 — Fragment caching, from first principles
A fragment cache is one idea: wrap a chunk of view in cache and store the rendered HTML under a key, so next time you skip the rendering and hand back the stored string. The entire mechanic lives in what that key is made of.
Here is Campfire's message partial. The first executable line (messages/_message.html.erb:3):
Fizzy reaches for the identical idiom — <% cache notification do %> (notifications/_notification.html.erb:1) — so handing cache a record, not a string, is the 37signals way, not a Campfire one-off.
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
<% cache message do %>
<%= message_tag message do %>
...the whole message: avatar, author, timestamp, body, boosts...
<% end %>
<% end %>
You passed cache a record, not a string key. That's the hinge. When you give cache an Active Record object, Rails builds the key for you out of three things: the model name, the record's id, and — the load-bearing part — the record's cache_version, which defaults to its updated_at timestamp. So the key is effectively messages/472-20260601.... You never typed a version number. The updated_at is the version number, and it's a column the database already maintains on every write.
This is the first half of the unifying mechanic: the key is derived from the data. Change the message — edit its body, and updated_at bumps — and the key changes, so the old fragment is abandoned and a fresh one is written. There is no v3 to remember, because "the content changed" and "the key changed" are the same event.
"What busts a fragment when the partial's markup changes, not the data?" The template digest. Rails hashes the partial's source (and its dependencies) and folds that hash into the key too — that's what the
<%# Be sure to check/update messages/_template.html.erb %>comment on line 1 is guarding, since_template.html.erbis a twin Rails can't see as a dependency. Edit_message.html.erband every fragment's key changes automatically on the next deploy. The only manual bump in the whole system is the one Rails can't infer — the hand-maintained twin — and they left a comment exactly there.
Fizzy hits the same blind spot and leaves the same kind of marker: a dated <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %> line right under its own cache (notifications/_notification.html.erb:2), because a helper's output isn't a dependency the digest can see either. Two products, the same escape hatch, in the same spot: a comment exactly where the convention goes blind.
The contrast: the naive version put "message-#{id}-v3" in the key and made you the version-control system — bump it on a markup change, bump it on a data change you can't otherwise express, and pray every call site agrees on the string. Campfire passes the record and lets updated_at + the template digest be the version. The v3 you'd maintain by hand is deleted.
Russian-doll: nesting fragments so a child busts its parent
Now the interesting case. A message contains boosts, and each boost is its own cached fragment (messages/boosts/_boost.html.erb:1):
<% cache boost do %>
<div id="<%= dom_id(boost) %>" class="boost ...">
...the booster's avatar, the emoji, a delete button...
</div>
<% end %>
So you have a fragment (the message) wrapping fragments (its boosts) wrapping nothing — a nested doll. The whole point of nesting is reuse: when one boost is added, you want to re-render that boost and re-stitch the message around it from already-cached pieces, not re-render every boost from scratch. But that only works if adding a boost busts the message's fragment — otherwise the message keeps serving its old cached HTML, boost row and all, and your new reaction never appears.
So the parent's key has to depend on its children. The message's key is its updated_at. Therefore a new boost must bump the message's updated_at. Here is how Campfire declares that — not with a callback, but one token on the association (boost.rb:2):
class Boost < ApplicationRecord
belongs_to :message, touch: true
end
touch: true means: whenever a boost is created, updated, or destroyed, touch the parent message's updated_at. That bumps the message's updated_at, which is the message's cache key, which expires its fragment — so the message re-renders, finds its boosts mostly warm in the cache, and stitches in the one new boost. You wrote no callback, no key, and no loop. And crucially touch: covers the destroy path the naive after_create forgot — Rails fires it on create, update, and destroy, so deleting a reaction busts the cache exactly like adding one.
Fizzy wires its freshness graph with the same one token — belongs_to :board, touch: true on the Column model (column.rb:5) — so a column's write bubbles up to its board exactly as a boost's does to its message. The declaration-on-the-association pattern is the 37signals way.
The same one token climbs another level. A message belongs_to :room, touch: true (message.rb:4), so writing a message bumps the room's updated_at — which reorders the room in the sidebar list and busts the room's own cached fragments. One declaration per relationship, made on the association that owns the relationship, and the entire freshness dependency graph is wired:
This is where the throughline shows up honestly: count the edge cases this line absorbs for free. touch: true on boost.rb:2 absorbs create-busts-cache, update-busts-cache, destroy-busts-cache, and reorder-the-room-list — across every path that ever touches a boost, including ones you haven't written yet. The naive after_create { message.update(...) } absorbs exactly one of those, the one you remembered, on the day you remembered it.
"Isn't re-rendering the parent on every child change the slow thing I was trying to avoid?" No — that's what the nesting buys you. The parent re-render is cheap because all the unchanged children are served from their own warm fragments; only the one that changed is rendered fresh. You pay for one boost, not two hundred messages. That's the "doll" in Russian-doll: each shell is cached independently, so busting the outer one doesn't re-cost the inner ones.
Rendering the collection: cached: true
There's one more mechanic, on the collection that holds all those message fragments. The room renders its messages like this (messages/index.html.erb:1, and identically at rooms/show.html.erb:17):
<%= render partial: "messages/message", collection: @messages, cached: true %>
Two things are happening. First, collection: @messages renders the partial once per message with no hand-written .each loop — the convention replaces the loop. Second, cached: true is the performance multiplier: instead of checking the cache store two hundred separate times (two hundred round-trips), Rails computes all two hundred keys up front and issues one batched read_multi to fetch every fragment it already has in a single call, then renders only the misses. A warm room of two hundred messages becomes one cache round-trip and zero re-renders.
Fizzy renders its notification tray the same way — render partial: "notifications/notification", collection: @notifications, cached: true (notifications/trays/show.html.erb:2) — and reuses that one partial for both the full-page render and the live turbo-stream appends, so the same warm fragments serve both paths. The cached-collection one-liner is the 37signals default, not a Campfire flourish.
The contrast: the naive @messages.each { |m| render "message", message: m } writes the loop by hand, caches nothing (or caches with Rails.cache.fetch inside the loop, which is N separate store hits and a hand-typed key), and gives you no batching. The conventional one-liner deletes the loop, derives every key, and batches the lookups — three jobs, one declaration.
Part 2 — HTTP caching, the other altitude
Fragment caching saves you from re-rendering. But the fastest request is the one whose body never runs at all — where the server looks at what the client already has, says "still good," and returns an empty 304 Not Modified. That's HTTP caching, and it's the same derived-key idea moved up to the controller, where the key is called an ETag.
Campfire's avatar endpoint is the clean example. Generating an avatar is expensive — it resizes the upload and re-encodes it to WebP on every hit. Here's the whole action (users/avatars_controller.rb:6-21):
def show
@user = User.from_avatar_token(params[:user_id])
if stale?(etag: @user)
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
if @user.avatar.attached?
avatar_variant = @user.avatar.variant(SQUARE_WEBP_VARIANT).processed
send_webp_blob_file avatar_variant.key
elsif @user.bot?
render_default_bot
else
render_initials
end
end
end
Read if stale?(etag: @user) as the gate over the entire expensive body. stale? computes an ETag from @user (again derived from the record — its cache_version / updated_at), compares it to the If-None-Match header the browser sent, and:
- Match → the user hasn't changed since the browser last fetched the avatar.
stale?returnsfalse, theifblock is skipped entirely, and Rails sends back a bare304 Not Modified. The WebP processing never runs. - No match → returns
true, the body runs, generates the avatar, and stamps the fresh ETag on the response so the next request can 304.
That's the payoff: on a cache hit, the most expensive line in the action — .variant(...).processed — is never reached.
Fizzy's avatar endpoint gates the same way — stale? @user, cache_control: cache_control with the same stale_while_revalidate: 1.week (users/avatars_controller.rb:12,39) — confirming stale? over the expensive body is the 37signals reflex for a cacheable image endpoint, in two products that share nothing else.
You didn't cache the output; you cached the decision to skip, derived from the record.
The line below it tunes who else gets to skip the work:
expires_in 30.minutes, public: true, stale_while_revalidate: 1.week
expires_in 30.minutes, public: true tells the browser and any shared CDN ("public") they may serve this avatar from their own cache for 30 minutes without even asking the server. stale_while_revalidate: 1.week is the graceful part: for up to a week after it goes stale, a cache may serve the slightly-old avatar immediately while it refreshes in the background — the user never waits on a regeneration. Freshness pushed all the way out to the edge, declared in one line next to the code it governs.
The same instrument, different settings, appears across the app's read-only endpoints:
# messages_controller.rb:10-18 — a 304 for an unchanged message list
def index
@messages = find_paged_messages
if @messages.any?
fresh_when @messages # the whole-collection ETag; 304 if unchanged
else
head :no_content
end
end
# qr_code_controller.rb:8 — a QR code for a URL never changes
expires_in 1.year, public: true
fresh_when @messages is just stale? written as a statement when you have nothing to do but render — it sets the ETag from the collection and lets Rails 304 automatically.
Fizzy uses the same statement form on its board view — fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ] (boards_controller.rb:95) — only with a composite ETag, so the 304 breaks if any of several inputs change. Same convention, tuned to a multi-factor key. The QR code is the extreme: a QR for a given URL is immutable, so cache it for a year.
"Why
stale?/ETags instead ofRails.cache.fetcharound the avatar?" BecauseRails.cache.fetchstill runs the action — it caches on your server and then re-sends the bytes down the wire every time.stale?reaches one level higher: it lets the browser's copy answer the question, so on a hit there's no rendering and no payload — an empty 304. For an avatar that's already sitting in the browser's cache, that's the difference between re-encoding a WebP plus shipping it, and shipping nothing. The naive instinct caches the body; the convention skips it.
The whole idea: derive the key, at every layer
Step back and the two parts are the same sentence at two altitudes: the key is derived from the data, never a hand-maintained version integer — if the content changes, the key changes. Fragment caching derives it from updated_at (via cache record) and propagates it with touch:. HTTP caching derives it into an ETag (via stale?/fresh_when). And it shows up a third time, at the route layer, in how Campfire builds an avatar URL (routes.rb:58-60):
direct :fresh_user_avatar do |user, options|
route_for :user_avatar, user.avatar_token, v: user.updated_at.to_fs(:number)
end
The avatar URL itself carries v: user.updated_at as a query param. Change your avatar, updated_at bumps, the URL changes — so the browser (and every CDN holding the old immutable copy) treats it as a brand-new resource and fetches fresh, with zero risk of serving the old image. It's the production config's own comment, made literal: "If the content change the URL too" (production.rb:23), which is exactly why /assets/ digested files can be marked immutable, max-age=1.year (production.rb:19-30) — their content hash is baked into the filename, so a changed asset is a changed URL by construction.
One idea — change the content, change the key — expressed as updated_at in a fragment key, an ETag in a 304, and a v: in a URL.
Three altitudes, no version integer maintained anywhere.
Where this points
Caching is a Foundations building block; the why behind it lives in two principles:
- see P2: Derive, Don't Store — the cache key is the purest case of derivation: a fact ("has this changed?") you must never store as a hand-maintained version, because the stored copy will disagree with the data.
derive, don't store, applied to freshness. - see P4: Convention Is Leverage —
touch:declares the freshness dependency once, on the association that owns the relationship, so the two sides of the boundary (the child that changes, the parent fragment that must expire) can never drift. The convention does the dependency-graph bookkeeping for you.
We didn't re-derive the _commit callback boundary here — that's F1: the callback lifecycle's and see P9: the _commit boundary's job; touch: fires on the ordinary write lifecycle, and that's all caching needs from it. And the dom_id(boost) inside the boost fragment is the same universal address from F3: Views, Partials & Helpers — here it just gives each cached doll a stable id to target.
Key Takeaways — Patterns to Steal
- The whole subject of this tutorial is one trap: a cache key is a second source of truth about "has this changed?", and the moment you maintain that truth by hand it drifts from the data and starts serving yesterday's HTML with no error to tell you. So never store the answer — derive it, so that "the content changed" and "the key changed" become the same event. Every move below is that one sentence at a different altitude.
- When you cache a fragment, hand
cachethe record itself, not a string you assembled —<% cache message do %>(_message.html.erb:3) lets Rails build the key from the model name, the id, and the record'scache_version, which is justupdated_at. The version you'd reach for first is a literal"message-#{id}-v3"that makes you the version-control system, bumpingv4across every call site and praying they agree. Theupdated_atis a column the database already bumps on every write, so the version maintains itself. - When a child record needs to expire its parent's cached fragment, declare it with
touch: trueon the association, not a callback that re-touches the parent by hand —belongs_to :message, touch: true(boost.rb:2) is the entire wiring. The naiveafter_create { message.update(updated_at: Time.current) }fires on exactly the one path you remembered and silently skips destroy, so deleting a reaction leaves it on screen until something else busts the cache.touch:fires on create, update, and destroy, so removing a boost expires the parent exactly like adding one. - Nest a cached fragment inside another cached fragment —
cache boost(messages/boosts/_boost.html.erb:1) living insidecache message(_message.html.erb:3) — so that adding one reaction re-renders just that message from its otherwise-warm boosts, not the whole room from scratch. The instinct that re-rendering the parent must be the slow thing you were avoiding is wrong here: every unchanged child is served from its own fragment, so you pay for one boost, not two hundred messages. That is the "doll" — each shell caches independently, so busting the outer one doesn't re-cost the inner ones. - Render a collection of cached fragments with
render partial: "messages/message", collection: @messages, cached: true(messages/index.html.erb:1, alsorooms/show.html.erb:17) and let one declaration do three jobs at once. The version you'd write first is@messages.each { |m| render "message", message: m }, which hand-writes the loop, derives no key, and — if you cache inside it — issues N separate store round-trips.collection:deletes the loop, andcached: truecomputes all the keys up front and fetches every warm fragment in a single batchedread_multi, turning a warm room into one round-trip. - For an expensive action, gate the entire body behind
if stale?(etag: @user)(users/avatars_controller.rb:9) so that on a match Rails returns a bare304 Not Modifiedand the costly.variant(...).processedWebP re-encode below it never runs at all. Reaching forRails.cache.fetcharound the avatar feels right but still runs the action and re-ships the bytes down the wire every time — it caches the output on your server.stale?reaches a level higher and lets the browser's copy answer "has this changed?", so a hit costs no render and no payload. When there's nothing to do but render, write it as the statement formfresh_when @messages(messages_controller.rb:14). - Make a changed resource into a changed URL so the old one can be cached forever with zero risk —
direct :fresh_user_avatarstampsv: user.updated_at.to_fs(:number)onto the avatar route (routes.rb:58-60), so the day someone uploads a new avatar the URL itself changes and every CDN holding the old immutable copy treats it as a brand-new resource. This is the same trick that lets digested/assets/files be markedimmutable, max-age=1.year(production.rb:19-30): the content hash is baked into the filename, so a changed asset is a changed URL by construction. It kills the entire class of "I shipped a new image and people still see the old one" bugs — the same derived-key idea asupdated_atin a fragment and an ETag in a 304, now living in a URL.