Caching strategies for high-traffic catalog pages
A catalog page that's fast for ten users can fall over for ten thousand. A deep-dive on the layers of caching that keep it fast — fragment caching, HTTP/CDN caching, low-level caching — and the hard part: invalidation and stampedes.
A product listing that renders in 80ms on your laptop with a handful of products can collapse under real traffic, because the same expensive work — querying, computing prices, rendering hundreds of product cards — runs on every single request, multiplied by every visitor. Catalog pages are read enormously more often than the catalog changes, which is the textbook condition for caching. The art is in choosing the right layer to cache at, and surviving the two things that make caching hard: invalidation and stampedes. Here is how the layers stack up.
Think in layers, from cheapest to broadest
There is no single “cache” — there is a stack of them, and the further from your application a request can be served, the cheaper it is. From innermost to outermost:
- Fragment caching — cache rendered pieces of a page in Rails.
- Low-level caching — cache the result of expensive computations or queries.
- HTTP caching — let the browser and intermediaries avoid the request entirely.
- CDN / edge caching — serve whole responses from the edge, never touching your app.
A robust catalog uses several of these together. The goal is to push as much work as possible outward — the best request is one your app never sees.
Fragment caching and Russian-doll keys
The Rails workhorse is fragment caching, and for a catalog the Russian-doll pattern fits perfectly: cache the listing, cache each product card inside it, and let the keys nest so changing one product expires only its card and the page wrapper — not every sibling:
<% cache ["v2", @products] do %>
<% @products.each do |product| %>
<% cache product do %>
<%= render product %>
<% end %>
<% end %>
<% end %>
The key is the whole game. Rails builds a cache key from the record and its updated_at
plus a digest of the template, so the cache invalidates itself: touch a product (or
touch: true up its associations) and its key changes, the old fragment is abandoned, and
the new one renders once. This is the cleanest form of invalidation there is — you do not
expire anything, you just key on something that changes when the content changes. Add a
manual version prefix ("v2") so you can bust everything at once after a markup overhaul.
Low-level caching for expensive computation
Some costs are not view rendering but computation — a faceted filter count, a “bestsellers”
ranking, an aggregate that takes a heavy query. Rails.cache.fetch caches the result,
keyed however you like, with an expiry:
def bestsellers
Rails.cache.fetch("bestsellers/#{category.id}", expires_in: 1.hour) do
Order.top_products_for(category) # the expensive query runs at most hourly
end
end
The pattern — fetch with a block that computes on miss — is the single most useful caching idiom in Rails. Use it for anything expensive that tolerates being slightly stale. The backing store should be Memcached or Redis (shared across all your servers), never the in-process memory store in production, or each server caches separately and your hit rate collapses.
HTTP and CDN caching: serve without touching the app
The biggest wins come from not running your app at all. For pages that are identical for many users — a public category page, a product detail page for anonymous visitors — HTTP caching lets the response be reused by browsers, proxies, and a CDN:
def show
@product = Product.find(params[:id])
fresh_when(@product) # sets ETag + Last-Modified from the record
end
fresh_when sets validation headers; if the browser re-requests and nothing changed, Rails
returns a tiny 304 Not Modified instead of re-rendering — work saved. Better still, set
Cache-Control so a CDN caches the whole page at the edge and serves it to thousands
of users without a single request reaching your origin. For anonymous catalog traffic this
is transformative: your servers handle cache misses and updates; the CDN handles the flood.
The catch is personalisation — a page showing “Hi, Dave” or a basket count cannot be shared
across users, so isolate per-user bits (load them via a separate request or edge-side
include) and keep the cacheable shell public.
The hard parts: invalidation and stampedes
Phil Karlton’s line — “there are only two hard things in computer science: cache invalidation and naming things” — is a joke that becomes very unfunny in production. The two problems that actually bite:
Invalidation. Stale data served confidently is worse than a slow page. The discipline is
to prefer key-based expiry (the Russian-doll approach, where the key changes with the
content) over manual expiry (remembering to delete the right keys when something changes),
because manual expiry is where the bugs live — you will forget one path, and a price will be
wrong on a cached page. When you must invalidate related caches (a category page when a
product in it changes), do it deliberately, ideally via touch: so the framework tracks the
dependency for you.
Cache stampede (the thundering herd). When a popular cached item expires, every
concurrent request misses at once and all of them run the expensive computation
simultaneously — often overwhelming the database at the exact moment the cache was supposed
to protect it. The defences: serve slightly-stale content while one process regenerates in
the background (race_condition_ttl in Rails helps), add small random jitter to expiry times
so popular keys don’t all expire on the same second, and pre-warm critical caches rather than
waiting for the first unlucky user to pay the cost.
Verdict
Caching a catalog is not one technique but a layered strategy: fragment-cache the rendered
pieces with self-invalidating Russian-doll keys, low-level-cache expensive computations with
fetch, and — the biggest lever — use HTTP and CDN caching to serve anonymous traffic
without your app running at all, isolating the personalised bits. Back it with a shared store
(Redis/Memcached), prefer key-based invalidation over manual expiry, and defend against
stampedes with stale-while-revalidate, jitter, and pre-warming. Layer those correctly and a
catalog page that buckled at ten thousand visitors serves a million comfortably — most of
them never touching your application at all, which is exactly the point.