Skip to content
← All posts
5 min read Dawid Skłodowski

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:

  1. Fragment caching — cache rendered pieces of a page in Rails.
  2. Low-level caching — cache the result of expensive computations or queries.
  3. HTTP caching — let the browser and intermediaries avoid the request entirely.
  4. 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.