LaunchKit
← All posts
· 17 min read · by The LaunchKit team · 1 views

Rails caching strategies, level by level

Rails has four places a request can stop early, and most discussions of rails caching collapse them into one. They are not one. They differ in what work they skip, in what has to change for them to be wrong, and in how loudly they fail. A fragment cache that is wrong renders yesterday's number to everybody. A conditional GET that is wrong sends a fresh body nobody needed. Same feature name, two very different Mondays.

Everything below was run against rails 8.1.3.1 on PostgreSQL 17.7, with the output pasted as it came out. The framework source quoted is from activerecord 8.1.3.1, actionview 8.1.3.1 and actionpack 8.1.3.1.

The four levels, ordered by what they skip

Conditional GET skips the response body. The controller still runs, the record is still loaded, the ETag is still computed, and then Rails answers 304 Not Modified with zero bytes. What you save is bandwidth and the browser's rendering, not your server's work.

Fragment caching skips template rendering. The block inside <% cache @post do %> is not executed on a hit, so every query inside it, every helper call, every render of a child partial is skipped with it. What you save is view time, and the queries that only the view was making.

Low-level caching skips a computation you name yourself. Rails.cache.fetch(key) { ... } stores the return value of a block, which can be a number, an array, a hash, anything the coder can dump. What you save is exactly what you put in the block, which is the level's whole appeal and its whole risk.

SQL caching skips a repeated query inside one request. Nothing to configure and nothing to invalidate, because the guide states the lifetime: "Query caches are created at the start of an action and destroyed at the end of that action and thus persist only for the duration of the action." It is the one level that cannot serve stale data, for the same reason it cannot help the next request.

The Rails guide lists these as types of caching in a section called "Types of Caching", in the order fragment, Russian doll, shared partial, low-level, SQL, with conditional GET in its own section at the end. That is a reasonable order to document them in. It is a bad order to reach for them in.

Which one to reach for first

Reach for them in the reverse of the order the guide lists them, roughly.

Look at the queries first, and not to cache them. A page rendering 40 rows and issuing 41 queries is not slow because rendering is expensive, and wrapping it in a fragment cache makes the cold render exactly as slow as it was while hiding the cause from every subsequent profile. N+1 queries in Rails is the fix; caching is the cover-up. This is the single most common thing fragment caching is used for and the one thing it should never be used for.

Then conditional GET, if the page is a document the same reader comes back to. fresh_when(@post) is one line, has no invalidation problem, and costs nothing when it misses.

Then the low-level cache, when the slow thing is a value rather than markup: a count, an aggregate, a parsed file, a response from somebody else's API. A count is the usual case, because PostgreSQL has no stored row count and SELECT COUNT(*) scans, which is the same tax that makes a page-number bar expensive in Pagination without a gem.

Fragment caching last, because it is the level that buys you a stale bug. Not never: a page whose cost genuinely is rendering, with dozens of partials and i18n lookups per row, is what fragment caching was built for, and on that page it should be first. The rest of this post is mostly about what fragment caching asks of you in return.

What cache_key and cache_version return

Everything above the SQL level agrees on one rule for naming a thing, and the rule lives on the record. Here is a real row from this site's leads table:

cache_key               = leads/3
cache_version           = "20260924142558288314"
cache_key_with_version  = leads/3-20260924142558288314
updated_at              = 2026-09-24T14:25:58.288314Z

The key is the class name and the id. The version is updated_at with the punctuation removed, to microsecond precision. ActiveRecord::Integration#cache_key builds the first, and the branch that matters is short:

def cache_key
  if new_record?
    "#{model_name.cache_key}/new"
  else
    if cache_version
      "#{model_name.cache_key}/#{id}"
    else
      timestamp = max_updated_column_timestamp
      ...

So cache_key asks cache_version whether it exists, and only appends a timestamp when it does not. cache_version returns nil unless cache_versioning is on, and class_attribute :cache_versioning is declared with default: false in the same file. The true you actually get comes from the framework defaults: railties' configuration.rb:139 sets active_record.cache_versioning = true, and line 179 sets collection_cache_versioning = true. Both are load_defaults values, which means an old application that never bumped its load_defaults is still on the 5.1 behaviour and does not know it.

Why cache_key_with_version exists

Two strings instead of one looks like ceremony until you watch the store. ActiveSupport::Cache splits them deliberately: normalize_key calls expanded_key, which calls the record's cache_key, while normalize_version calls expanded_version, which calls cache_version. The version is written into the entry, not into the key.

Write a value, touch the record, read again:

store keys after write  = ["leads/3"]
entry version stored    = "20260924142558288314"
hit same record         = RENDERED v1

after touch: cache_key  = leads/3
after touch: version    = "20260924142845946287"
fetch after touch       = RECOMPUTED v2
store keys now          = ["leads/3"]
entry count             = 1

One key, one entry, before and after. The stale value was overwritten in place, because the address never moved. Entry#mismatched? is the whole mechanism: @version && version && @version != version.

Now the same two writes with ActiveRecord::Base.cache_versioning = false:

cache_versioning=false  cache_key = leads/3-20260924142845946287
cache_versioning=false  version   = nil
keys with versioning off = ["leads/3-20260924142845946287", "leads/3-20260924142845955054"]

Two keys. The first one is now unreachable garbage that will sit there until the store evicts it, and a record updated a hundred times leaves a hundred of those. That is what "recyclable cache keys" recycles, and it is the reason cache_key_with_version exists as a separate method: it is the old Rails 5.1 key, kept for the places that still need one string, and expand_cache_key still reaches for it.

The store has to cooperate, and Rails checks. active_record.cache_versioning_support in railtie.rb:116 raises at boot with "You're using a cache store that doesn't support native cache versioning" unless Rails.cache.class.supports_cache_versioning? answers true. SolidCache::Store.supports_cache_versioning? returns true, so the boilerplate this site sells, which sets config.cache_store = :solid_cache_store in production, never meets that message.

Reading a real fragment cache key

A <% cache @lead do %> block in a template named scratch/show.html.erb produced exactly this key:

views/scratch/show:4fc001203c7d63ff06668144385c008b/leads/3

Four pieces. views is the namespace Action Controller adds. scratch/show is the template's virtual path. The hex is a digest of that template and its dependencies. leads/3 is the record's cache_key, and the version 20260924142558288314 went into the entry rather than into that string.

The digest is the interesting half, because it is the one thing in rails fragment caching that is invalidated by a deploy rather than by a write. digest_path_from_template calls Digestor.digest(name:, format:, finder:, dependencies:) and joins the result onto the virtual path. The digest covers not just this template but everything it renders, which means editing a child partial moves the parent's digest too:

digest scratch/index    = b552b73304af9fcd1ac115f9f17e6bf6
digest scratch/_comment = 0c339ff33c7805208ae094b1193d0b6a

after editing _comment.html.erb:
digest scratch/index    = a5ba587621bd35561c3228c177f64410
digest scratch/_comment = e00a661a4db62744fdad5c552d9ffe11

Both moved, from a one word edit in the child. That is the feature: you change markup, every fragment that could contain that markup gets a new address, no cache clearing step in the deploy script. The bill arrives immediately after, in the store:

views/scratch/_comment:0c339ff33c7805208ae094b1193d0b6a/comments/5
views/scratch/_comment:e00a661a4db62744fdad5c552d9ffe11/comments/5
views/scratch/index:a5ba587621bd35561c3228c177f64410/leads/3
views/scratch/index:b552b73304af9fcd1ac115f9f17e6bf6/leads/3

Four entries where there were two. Every template edit doubles the fragments for that template until eviction catches up, which is a size problem rather than a correctness problem, and the size of your cache is the argument Solid Cache vs Redis is about.

Russian doll caching needs touch: true

Nest a cache on a child inside a cache on its parent and you have russian doll caching: the outer fragment holds the rendered children, and a child that changes should invalidate only its own fragment plus the shell around it.

The word "should" is doing a lot of work there. The template dependency is tracked. The data dependency is not. Here is a parent cached on @post, rendering lines cached on each line, with belongs_to :scratch_post and no touch:

render 1: OUTER(Post)[LINE(first)]
render 2: OUTER(Post)[LINE(first)]   <- after the line row was updated to EDITED

The line's own fragment was ready to expire: its cache_version moved with the row. Nobody asked it, because the outer fragment was a hit and the block that would have rendered the line never ran. The parent's updated_at did not move, so its key and its version are what they were, and the page is frozen at the moment of the last parent write.

Add touch: true to the child's belongs_to and the second render is right:

render 1: OUTER(Post2)[LINE(first)]
render 2: OUTER(Post2)[LINE(EDITED)]   <- with touch: true

The Rails guide describes the failure in the same terms: "because updated_at will not be changed for the product object, that cache will not be expired and your app will serve stale data".

The UPDATE that touch actually emits

touch: true is not a callback that writes a cache. It writes a column, in the same transaction as the child's write, and you can read it in the log. Creating, updating and destroying one child of a touched parent:

INSERT INTO "scratch_lines" ("scratch_post_id", "body", "created_at", "updated_at") VALUES (1, 'one', ...)
UPDATE "scratch_posts" SET "updated_at" = '2026-09-24 14:23:18.909066' WHERE "scratch_posts"."id" = 1

UPDATE "scratch_lines" SET "body" = 'one edited', "updated_at" = ... WHERE "scratch_lines"."id" = 1
UPDATE "scratch_posts" SET "updated_at" = '2026-09-24 14:23:18.913872' WHERE "scratch_posts"."id" = 1

DELETE FROM "scratch_lines" WHERE "scratch_lines"."id" = 1
UPDATE "scratch_posts" SET "updated_at" = '2026-09-24 14:23:18.915558' WHERE "scratch_posts"."id" = 1

Three writes, three parent updates, all three inside the child's transaction. Without the option, the same create and update left the parent's cache_version on 20260924142318915558 before and after.

Price the extra UPDATE before you add the option. Every write to a child becomes a write to the parent row, so a parent with a thousand children gets a thousand times the write volume it had, all of it contending on one row. Fifty comments a second landing on one hot post is fifty updates a second to that post's row. And anything that writes children without instantiating them, delete_all, update_all, insert_all, a row written by psql, touches nothing, which is the same blind spot counter_cache has in Counter caches by hand.

touch: true is the invalidation that works because it is declarative: it is one option on the association, and it fires for every create, update and destroy that goes through Active Record, including the ones written six months from now by somebody who has never read this page.

The invalidation that does not work

The alternative is naming the key yourself and expiring it yourself. It fails, and it fails quietly:

--- hand-written key, no version ---
  fetch -> computed 0
  fetch -> computed 0
  fetch -> computed 0
  keys: ["lead-summary-3"]

Three fetches, with a touch between each, all returning the first value. "lead-summary-#{lead.id}" is a String, and a String has no cache_key and no cache_version, so normalize_version returned nil and there was never anything to mismatch. The entry is correct forever, for a definition of correct fixed at the moment it was written.

Put the record in the key instead and the version comes back:

--- array key with the record in it ---
  fetch -> computed 0
  fetch -> computed 1
  keys: ["summary/leads/3"]
  normalize_key(['summary', lead])  = "summary/leads/3"
  normalize_version                 = "20260924142558288314"
  entry.version                     = "20260924142558286402"
  mismatched?                       = true

Rails.cache.fetch(["summary", lead]) costs the same keystrokes as the string and expires on its own. Prefer it. The escape hatch, the one that does not work, is an expire_lead_summary method called from three places and forgotten in the fourth: a background job, an admin action, a rake task, an import. Nothing fails when it is forgotten. A reader sees a number that was true in March.

When the cached value genuinely does not belong to one record, use expires_in and accept a window of staleness rather than inventing a manual expiry. A wrong number for five minutes is a product decision. A wrong number until somebody notices is not.

The dependency the digester guesses wrong

Template digests are computed by parsing the template for render calls, and the parser only sees what it can read statically. Two templates rendering the same partial, one with a literal and one with a local variable:

dep/static  dependencies: ["dep/inner"]
dep/dynamic dependencies: ["names/name"]

names/name is not a typo and not a file. It is a guess, produced by this line in actionview's render_parser/prism_render_parser.rb:132:

"#{dependency.pluralize}/#{dependency.singularize}"

The local variable is called name, so the parser pluralises and singularises it and writes down names/name. The heuristic is right for the idiomatic case, render @comments meaning comments/_comment, and wrong for everything else. Edit the real partial and watch:

static  digest after: 088edcc2e3a4fdc9025bbcb2744f516f  changed=true
dynamic digest after: 29445d3e8a8a7ab268146b443e59cea6  changed=false
  static  -> INNER-V2
  dynamic -> INNER-V1

The dynamic template's fragment survives the deploy that changed its contents, and keeps surviving, because nothing in its key ever moves again. In production this is a component that will not update until the record it is keyed on happens to be written.

The fix is a comment, which is the part that reads like a joke and is the documented answer:

<%# Template Dependency: dep/inner %>

With it, the dependency list became ["names/name", "dep/inner"], the digest moved, and the second render returned INNER-V2. The wrong guess stays in the list, harmlessly, because a digest of a template that does not exist contributes nothing.

Conditional GET runs on the same key rule

The level above all of these needs no store at all. fresh_when(@lead) in a controller:

1st  status=200  bytes=6
     ETag: W/"ee63f0f355ffd2aff4508ae44aa02b0c"
     Last-Modified: Thu, 24 Sep 2026 14:25:06 GMT
     Cache-Control: max-age=0, private, must-revalidate
with If-None-Match:     status=304  bytes=0
with If-Modified-Since: status=304  bytes=0
after touch, same If-None-Match: status=200  bytes=6
     new ETag: W/"9378ee687aed4f02d42670a406227370"

The ETag is not a hash of the body. fresh_when sets weak_etag ||= etag || object, and generate_weak_etag digests ActiveSupport::Cache.expand_cache_key(validators), which for a record returns leads/3-20260924142558288314. The same cache_key_with_version that addresses a fragment addresses the HTTP response, which is why a record that invalidates one invalidates the other and why a model whose updated_at lies breaks both at once.

last_modified comes from a second line of the same method, object.try(:updated_at) || object.try(:maximum, :updated_at), and the maximum branch is there so you can hand fresh_when a whole relation.

For a page with no per-reader content, expires_in 1.hour, public: true produces Cache-Control: max-age=3600, public, which lets a CDN answer for you and is the cheapest caching in this post. This site declares it in four places and no others: the sitemap and both llms.txt endpoints at an hour, robots.txt at a day. Everything else it serves is either static or cheap enough not to bother.

The collection key costs a query

cache on a relation rather than a record works, and is not free:

relation.cache_key              = scratch_lines/query-37a47eb54d3b80d47c1919a5d53528eb
relation.cache_version          = 4-20260924142318921737
relation.cache_key_with_version = scratch_lines/query-37a47eb54d3b80d47c1919a5d53528eb-4-20260924142318921737

The key hashes the SQL. The version is the row count and the newest updated_at, joined, and getting them runs a statement:

SELECT COUNT(*) AS "size", MAX("scratch_lines"."updated_at") AS timestamp FROM "scratch_lines" WHERE ...

A count and a max, on every request, to decide whether to skip work. On an indexed few thousand rows that is a good trade. On a large table it is the aggregate scan you were trying to avoid, arriving one level up.

The related option is worth knowing for collections of partials. render partial: "row", collection: @rows, cached: true on five rows produced one read_multi and one write_multi on the cold render, and a single read_multi on the warm one, instead of five round trips each way. On a store where a round trip is a SQL query rather than a hash lookup, that difference is the whole feature.

Your test suite sees none of this

Both this site and the boilerplate configure the test environment the same way, and the combination is worth reading twice:

config.cache_store = :null_store
config.action_controller.cache_store = :memory_store

ActionController::Base.perform_caching is true in test, a framework default neither application overrides, so cache blocks are not skipped: the digest is computed, the key is assembled, the entry is written. Into a NullStore, which answers every read with a miss. A fetch there returned computed 0, then computed 1, then computed 2.

So a spec renders the fragment every time and always sees fresh data. A wrong cache key, a missing touch: true, a render partial: name the digester could not follow: all green. The second store line exists only so rate_limit has somewhere real to count, which is the setup Running Rails 8 without Redis pulls apart.

Development is the mirror image. perform_caching is false there unless tmp/caching-dev.txt exists, and ActionView::Helpers::CacheHelper#cache handles that by yielding:

if controller.respond_to?(:perform_caching) && controller.perform_caching
  ...
else
  yield
end

No caching, no warning, no log line. bin/rails dev:cache toggles the file, and running a page once with it on is the only cheap way to see your own keys before production does.

The call, and what would change it

Use touch: true for anything a fragment is keyed on, and write no expire_ methods at all. The extra UPDATE per child write is a real cost and it is still the right one, because it is the only invalidation that a developer who has not read your caching code cannot forget to call.

Key low-level caches on the record, not on a string with an id interpolated into it. ["summary", lead] and "lead-summary-#{lead.id}" are the same length and one of them expires.

Leave fragment caching out until you have measured that rendering is the cost. Most pages that feel slow are issuing too many queries, and a fragment cache around an N+1 is a way of never finding out.

What would change it: a touch: true whose parent write were deferred or coalesced per transaction would remove the write-contention objection and make it the default rather than a judgement. And a dependency tracker that could follow a dynamic render, or refuse to digest a template it could not parse, would turn the quietest failure in this post into a boot error, which is where it belongs.

The position has a cost worth naming. Keying on updated_at means anything that touches updated_at for another reason invalidates every cache on that row, and a view counter or an import is exactly that. The counter on this site writes views_count with a raw update_all that deliberately leaves updated_at alone, for a sitemap reason rather than a caching one, and the caching reason would have been just as good.

What this post does not cover

The boilerplate this site sells has no cache call in any view, no Rails.cache call anywhere, and no touch: true on any association. It configures :solid_cache_store in production, which rate_limit then leans on, declares stale_when_importmap_changes in ApplicationController, and sets expires_in on its sitemap and its robots.txt. That is the entire list, and it is all at the two levels that cannot go stale. Everything else above is framework behaviour reproduced in scratch scripts rather than a tour of the product, and saying so is cheaper than pretending otherwise.

Also absent: page and action caching, which left the framework in Rails 4 and live in gems; cache_if and cache_unless, which are the same mechanism with a predicate; key eviction, trimming and what a full store does to your hit rate, which is Solid Cache's own subject; cache stampedes and race_condition_ttl, which need concurrency to demonstrate and deserve their own measurements; and any timing figure, because the numbers here are keys, statements and status codes, and those reproduce on your machine.

#rails #caching

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.