Isometric illustration of stacked cache layers, from a wide outer layer down to the database at the centre

The best Rails caching strategy is a layered one: let browsers and CDNs answer repeat requests with HTTP caching, cache rendered HTML with fragment and Russian doll caching, cache expensive data with Rails.cache.fetch, and let Rails’ per-request query cache handle the rest. Each layer avoids a different kind of work and expires in a different way, so choosing the right one for each page matters more than choosing the “fastest” cache store. This guide compares every Rails caching strategy, the cache stores behind them (Solid Cache, Redis, Memcached), how to invalidate safely, and which combination fits which kind of app.


TL;DR

  • HTTP caching (fresh_when, expires_in) is the cheapest layer: a 304 Not Modified or a CDN hit skips rendering entirely.
  • Fragment caching with key-based expiration (cache @record) is the workhorse for server-rendered pages. Russian doll nesting plus touch: true keeps it correct.
  • Low-level caching (Rails.cache.fetch) is for computed data and third-party API responses. Use versioned keys and a TTL.
  • Cache size often matters more than read speed: 37signals found Solid Cache reads about 40% slower than Redis, but a 6× larger cache cut Basecamp’s p95 from 375 ms to 225 ms.
  • Prefer key-based expiration over deleting cache entries by hand. Stale-cache bugs nearly always come from manual invalidation.

Table of Contents

What caching strategies does Rails offer?

Rails offers caching at five layers: HTTP responses (browser and CDN), rendered fragments, arbitrary data through Rails.cache, SQL results within a request, and in-process memoization. Each layer saves different work.

Cache layers from the browser to the database, with what each saves and how it expires

Strategy What it saves Where it’s stored Expires by Best for
HTTP conditional GET (fresh_when, stale?) Rendering and body transfer Browser ETag / Last-Modified Show pages, APIs
HTTP expiry (expires_in) + CDN The whole request reaching Rails Browser, CDN TTL, purge Public pages, assets
Fragment caching (cache) Rendering and the queries inside Cache store Key changes (record version + template digest) Server-rendered views
Russian doll caching Re-rendering unchanged children Cache store touch: true cascades Nested views
Collection caching (cached: true) N cache round-trips Cache store Per item key Lists
Low-level (Rails.cache.fetch) Queries, computation, API calls Cache store Versioned key + TTL Aggregates, external data
SQL query cache Identical queries in one request Process memory End of request, any write Automatic
Memoization (@x ||=) Repeated work in one object Object Object lifetime Per-request helpers
Counter caches COUNT(*) queries Database column Maintained on write Counts on list pages

Page and action caching were removed from Rails core in Rails 4 and live on as the actionpack-page_caching and actionpack-action_caching gems. HTTP caching with a CDN covers most of what page caching used to do.

HTTP caching: let the browser and CDN do the work

HTTP caching is the cheapest strategy because a cache hit never reaches Rails, or reaches it only long enough to answer 304 Not Modified.

Conditional GET compares an ETag or a Last-Modified date with what the browser already has:

# app/controllers/articles_controller.rb
def show
  @article = Article.find(params[:id])
  fresh_when @article, public: true
end

If the article hasn’t changed since the browser’s last request, Rails answers 304 Not Modified without rendering the view. With stale? you can skip expensive work only when needed:

def show
  @article = Article.find(params[:id])
  if stale?(@article)
    @related = RelatedArticles.for(@article)   # only computed on a cache miss
    render :show
  end
end

Expiry headers and a CDN let shared caches serve a response for a fixed time without asking Rails at all:

# app/controllers/pages_controller.rb
def pricing
  expires_in 10.minutes, public: true, stale_while_revalidate: 1.minute
end

Only mark responses public if they’re identical for every visitor. Anything with a logged-in user’s name, a CSRF token or a flash message must stay private. Assets are a special case: their digested filenames make a one-year immutable cache safe, as covered in the assets precompile guide.

Fragment and Russian doll caching

Fragment caching stores rendered HTML for part of a view, keyed by the record’s version and a digest of the template. When either changes, the key changes, and the old entry is never read again.

<%# app/views/projects/show.html.erb %>
<% cache @project do %>
  <h1><%= @project.name %></h1>

  <% cache [@project, "todo_lists"] do %>
    <%= render partial: "todo_lists/todo_list", collection: @project.todo_lists, cached: true %>
  <% end %>
<% end %>
# app/models/todo_list.rb
class TodoList < ApplicationRecord
  belongs_to :project, touch: true
  has_many :todos
end

# app/models/todo.rb
class Todo < ApplicationRecord
  belongs_to :todo_list, touch: true
end

When a single todo changes, touch: true updates its list’s and its project’s updated_at. The project and that list re-render. Every other list’s fragment is reused from the cache. That’s Russian doll caching: the outer dolls are rebuilt, the untouched inner dolls aren’t.

Pros and cons of fragment caching:

  • ✅ Large savings on read-heavy pages, with correctness handled by keys.
  • ✅ Template digests invalidate fragments automatically when you change a view.
  • ❌ touch: true adds writes, which can become a hotspot on very busy parent records.
  • ❌ Per-user content needs the user in the key (cache [current_user, @project]), which lowers the hit rate.

Low-level caching with Rails.cache.fetch

Low-level caching stores any serializable value: computed aggregates, rendered JSON, or responses from slow third-party APIs.

# app/models/account.rb
def dashboard_stats
  Rails.cache.fetch([self, "dashboard_stats", "v2"], expires_in: 15.minutes, race_condition_ttl: 10.seconds) do
    {
      revenue_30d: invoices.paid.where(paid_at: 30.days.ago..).sum(:total_cents),
      active_users: users.where(last_seen_at: 7.days.ago..).count,
      open_tickets: tickets.open.count
    }
  end
end

The key includes the record (and its version), a name and a schema version ("v2"). Bump the version when you change the shape of the cached value, so old entries with the previous shape aren’t read by new code. race_condition_ttl prevents a stampede where many processes recompute the same expired key at once.

The production setup for a Redis-backed store, including timeouts and error handling, is in the Rails Redis caching guide.

Query cache, memoization and counter caches

These three need no cache store, and they’re often the quickest wins.

  • The SQL query cache is on by default for every request and job. Identical SELECTs return the in-memory result. It’s cleared after any write, so it never serves stale data within a request. You’ll see it as CACHE Product Load in development logs.
  • Memoization (@current_account ||= ...) avoids repeating work within one object or request. Never memoize at the class level across requests. That’s how memory leaks start, as covered in the memory leak guide.
  • Counter caches replace COUNT(*) with a column that Rails maintains:
# db/migrate/20261024090000_add_comments_count_to_posts.rb
class AddCommentsCountToPosts < ActiveRecord::Migration[8.0]
  def change
    add_column :posts, :comments_count, :integer, default: 0, null: false
  end
end
# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :post, counter_cache: true
end
bin/rails runner 'Post.find_each { |p| Post.reset_counters(p.id, :comments) }'

Which cache store should a Rails app use?

Use Solid Cache, the Rails 8 default, unless you have a specific reason not to: it needs no extra service and can hold a much larger cache for the money. Choose Redis or Memcached when you need the lowest read latency or a very high write rate.

Store Storage Read latency Cache size for the money Shared across servers Extra service Notes
:memory_store Process RAM Fastest Tiny, per process No No Dev, single-process apps only
:file_store Local disk Fast Medium, per server No No Breaks with multiple servers
:solid_cache_store SQL database (SSD) ~1 ms-ish, slower than RAM Very large, cheap Yes No Rails 8 default, FIFO eviction
:redis_cache_store RAM Sub-millisecond Limited by RAM Yes Yes Needs allkeys-lfu eviction
:mem_cache_store RAM Sub-millisecond Limited by RAM Yes Yes Simple, LRU, no persistence

The most useful real-world number here comes from 37signals, who moved Basecamp’s cache from Redis to Solid Cache. Reads got about 40% slower, but the cache became 6× larger on storage that’s 80% cheaper, and because far more fragments stayed cached, Basecamp’s 95th-percentile request duration fell from 375 ms to 225 ms. For fragment-heavy apps, hit rate beats raw speed.

Solid Cache’s defaults are a good start: entries older than two weeks (max_age) are removed, and you can cap max_size to stay within your disk budget:

# config/cache.yml
default: &default
  store_options:
    max_age: <%= 30.days.to_i %>
    max_size: <%= 2.gigabytes %>
    namespace: <%= Rails.env %>

production:
  database: cache
  <<: *default

Put the cache in its own database, as the Rails 8 generator does, so cache writes don’t compete with your application tables. That extra database also counts towards your connection total, as explained in the connection pool guide.

How do you invalidate the Rails cache safely?

Invalidate by changing the key, not by deleting entries. Key-based expiration means a cached value can never be stale for its key, because any change to the underlying data produces a new key.

Invalidation method How Risk
Key-based (recommended) Key includes cache_key_with_version, template digest, schema version Old entries linger until evicted (harmless)
TTL expires_in: 15.minutes Data can be up to 15 minutes old
Touch cascades belongs_to ..., touch: true Extra writes on parent records
Explicit delete Rails.cache.delete(key) in callbacks Easy to miss a code path, leading to stale data
Global clear Rails.cache.clear Cold cache, load spike on the database

A few rules that prevent most stale-cache bugs:

  1. Include everything the output depends on in the key: the record, the current user if it’s personalised, the locale, and a version string for the data shape.
  2. Combine key-based expiration with a TTL for anything that depends on data you don’t control, like third-party APIs or exchange rates.
  3. Avoid Rails.cache.clear in deploys. Template digests already handle view changes. Clearing everything at once sends every request to the database at the same moment.
  4. Watch the hit rate. A sudden drop after a deploy usually means a key now includes something that changes on every request.

How Heroku, Render, Fly.io, Upsun and Enkihost support Rails caching

Every platform supports Rails’ cache stores. They differ in which managed store they offer and whether Solid Cache’s database can live alongside your primary one.

Platform Managed in-memory store Solid Cache CDN Notes
Heroku Heroku Key-Value Store; Memcached via add-ons (e.g. MemCachier) On Heroku Postgres (a separate database needs another add-on) Via add-ons Key-Value Store defaults to noeviction; switch to allkeys-lfu for caches
Render Render Key Value (Valkey) On Render Postgres Built-in for static sites; use a CDN for apps Choose allkeys-lru at creation for caches
Fly.io Upstash Redis (fly redis create) On Managed Postgres Use an external CDN Enable eviction on Upstash for caches
Upsun redis and memcached services On the postgresql service Fastly integration on some plans Ephemeral redis service is meant for caches
Enkihost Redis included (128 MB on Ignite) On the included PostgreSQL (one database on Ignite, shared with the primary) None; use an external CDN Eviction policy not documented; check CONFIG GET maxmemory-policy

Enkihost includes PostgreSQL and Redis, injected as DATABASE_URL and REDIS_URL, so both Solid Cache and :redis_cache_store work without extra wiring.

Which caching strategy fits your app?

Pick the layers that match how your pages are used. Most apps need two or three, not all of them.

App type Start with Add next Store
Content site, blog, marketing pages HTTP expiry + CDN, fresh_when Fragment caching for shared partials Solid Cache
SaaS dashboard (per-account data) Low-level caching of aggregates per account Fragment caching keyed by account Solid Cache, or Redis if write-heavy
Collaboration app (Basecamp-like) Russian doll fragment caching with touch: true Collection caching Solid Cache (large cache wins)
Public JSON API Conditional GET with ETags Low-level caching of serialized responses Redis or Solid Cache
E-commerce catalogue Fragment caching of product cards, CDN for images Low-level caching of prices and stock with short TTLs Redis for hot keys, Solid Cache for fragments
App calling slow third-party APIs Low-level caching with TTL and race_condition_ttl Serving the last good value on errors Either

Author Perspective: cache the page people actually visit

Caching projects tend to start in the wrong place. Someone wraps a dozen small partials in cache blocks, the hit rate is poor, and the app is barely faster. What works for me is starting from the traffic: find the two or three pages that get most of the requests, look at why they’re slow, and cache those properly, usually with one outer fragment keyed on the right record, touch: true on its children and collection caching for the list. Then add HTTP caching for anything public. And measure the hit rate, because a cache you can’t see is a cache you can’t trust.

Caching on Enkihost

A good caching strategy needs a shared store, and a database that can hold a large cache if you choose Solid Cache. On Enkihost:

Enkihost

  • PostgreSQL add-on that injects DATABASE_URL, so Solid Cache works with Rails 8 defaults and no extra service.
  • Redis add-on that injects REDIS_URL when you need sub-millisecond reads or already run Sidekiq.
  • Per-app resource isolation, so cache hit latency and memory don’t depend on other tenants.
  • Zero-downtime deploys: template digests change fragment keys on deploy, and the old release keeps serving its own cached fragments until the switch.

Rails and Sinatra apps run on Ignite (9 EUR per month after a 14-day free trial, with PostgreSQL and Redis included) or Blaze (29 EUR per month, with high availability and autoscaling). The free Spark plan is for Jekyll sites. See enkihost.com.

FAQ

What is Russian doll caching in Rails?

Russian doll caching nests fragment caches inside each other, such as a project fragment containing todo list fragments. With touch: true on the child associations, changing one inner record updates its parents’ timestamps, so only the affected fragments re-render while all unchanged inner fragments are reused from the cache.

Is Solid Cache faster than Redis?

No, individual reads are slower. 37signals measured Solid Cache reads about 40 percent slower than their previous Redis cache. But because a disk-backed cache can be much larger for the same cost, more requests hit the cache, and Basecamp’s p95 request time dropped from 375 to 225 milliseconds after switching.

Does Rails cache in development by default?

No. Caching is off in development unless you run bin/rails dev:cache, which toggles perform_caching and switches the cache store. The SQL query cache, however, is active in every environment.

When should I use HTTP caching instead of fragment caching?

Use HTTP caching when the whole response can be reused, either by the same browser via ETags or by every visitor via a CDN. Use fragment caching when parts of a page are reusable but the page as a whole is personalised, for example a product list inside a page with the user’s cart.

How do I clear the Rails cache?

Run Rails.cache.clear from a console or runner, but avoid it in routine deploys. Template digests already invalidate view fragments when templates change, and versioned keys handle data changes. Clearing everything at once forces every request to recompute, which can overload the database.

Sources