Guide

Choosing a caching strategy that won’t bite you later

A good caching strategy answers four questions for each piece of data: where the cache sits (browser, CDN, application, database), how it’s filled (cache-aside, read-through, write-through), how it’s invalidated (TTL, explicit deletes, versioned keys), and what happens when it’s cold. For most web apps, the right default is HTTP caching at the edge plus cache-aside in Redis with short TTLs.

Caching is the fastest way to make a slow app fast and the fastest way to introduce bugs nobody can reproduce. This guide covers the patterns, the most common failure modes in SaaS codebases, and how to run Redis without surprises.

Before you cache: fix the query

Caching a slow query hides it; it doesn’t fix it. The cache will be cold after every deploy, every eviction and every new tenant, and users will hit the slow path at exactly the wrong moments. Check the basics first: N+1 queries, missing indexes, unbounded result sets, and expensive serialization. Postgres can usually serve well-indexed queries in milliseconds, and that’s often fast enough with no cache at all. If the database is the real bottleneck, start with PostgreSQL performance tuning.

Cache when the computation is expensive and the result is read far more often than it changes – rendered fragments, aggregated counts, permission sets, third-party API responses, configuration.

The caching layers, from outside in

  • Browser cache: controlled by Cache-Control headers. Free and fast, but you can’t invalidate it, so only cache what’s safe to be stale or uses fingerprinted URLs.
  • CDN or edge cache: Cloudflare, Fastly, CloudFront. Caches responses close to users. Ideal for static assets, marketing pages and public API responses.
  • Application cache: Redis or Memcached, shared across app instances. Where most SaaS caching lives.
  • In-process cache: a memory cache in each app process. Fastest of all, but every instance has its own copy, so invalidation is harder. Good for small, rarely changing data like feature flags with a short TTL.
  • Database cache: Postgres’s shared buffers and the OS page cache. Already there; make sure your working set fits in memory before adding anything else.
  • Materialized views and summary tables: precomputed results inside the database. Often a better fit than an external cache for reporting.

Cache-aside, read-through, write-through, write-behind

These describe who is responsible for filling and updating the cache:

  • Cache-aside (lazy loading): the app checks the cache; on a miss, it loads from the database, writes to the cache, and returns. Writes go to the database and delete (not update) the cache key. Simple, resilient to cache outages, and the right default. Rails.cache.fetch, Django’s cache.get_or_set and Laravel’s Cache::remember all implement it.
  • Read-through: the same flow, but the cache layer loads the data itself via a configured loader. Common in caching libraries and some managed services; behaviorally similar to cache-aside.
  • Write-through: every write goes to the cache and the database together, so the cache is always warm for that key. Useful for data that’s read immediately after it’s written, but you cache plenty of data that’s never read.
  • Write-behind (write-back): writes go to the cache and are flushed to the database later. Fast, and risky – a cache failure loses writes. Reserve it for data you can afford to lose, like view counters, and flush often.

On writes, prefer deleting the key over updating it. Two concurrent writers updating a cache can leave it holding the older value; deleting means the next read loads the truth.

HTTP caching, done properly

HTTP caching is the cheapest performance win in most SaaS apps and the most underused. The headers that matter:

  • Cache-Control: public, max-age=31536000, immutable for fingerprinted assets (app-3f9a2c.js). They never change at that URL, so cache them forever.
  • s-maxage sets the CDN’s TTL separately from the browser’s. Cache marketing pages at the edge for minutes while browsers revalidate.
  • stale-while-revalidate lets a cache serve a stale response while it fetches a fresh one in the background – near-instant responses with bounded staleness.
  • ETag and Last-Modified let clients revalidate with a conditional request and get a 304 with no body. Rails’ fresh_when and Django’s condition decorator make this simple for API responses.
  • Cache-Control: private or no-store for anything user-specific. Never let a CDN cache a response that depends on a session cookie, and set Vary correctly when responses differ by header.

Invalidation strategies that actually work

Phil Karlton’s line about cache invalidation being one of the two hard things in computer science is quoted so often because it’s true. The approaches, roughly from simplest to most precise:

  • TTL only. Accept that data may be stale for up to N seconds. The simplest strategy and often enough – a dashboard count that’s 60 seconds old is fine.
  • Explicit deletion on write. Delete the key when the underlying record changes. Precise, but you must find every write path, including bulk updates, admin tools and background jobs.
  • Versioned keys. Include a version or updated_at timestamp in the key (project/42-20261110093012). A change produces a new key; old entries age out. This is the idea behind Rails’ key-based expiry and Russian doll caching (since Rails 5.2 the version is stored with the entry under a stable key, to the same effect), and it removes most invalidation bugs.
  • Namespace bumps. Store a version number per tenant or per data type and include it in every key. Incrementing it invalidates everything in that namespace at once.
  • Event-driven invalidation. Publish change events and let a consumer delete affected keys. Useful when many services cache the same data; see event-driven architecture.

Always set a TTL, even on explicitly invalidated keys. It caps the damage from the invalidation path you forgot.

Cache stampedes and how to prevent them

A stampede (or thundering herd) happens when a popular key expires and hundreds of requests miss at once, all recomputing the same expensive value and hammering the database. It’s a classic cause of outages that start right after a deploy or a cache flush. Defences:

  • Request coalescing: only one process recomputes; others wait or serve stale. Implement with a short-lived Redis lock (SET key value NX PX 5000) around the recompute.
  • Serve stale while recomputing. Store the value with a soft expiry earlier than the hard one; the first request past the soft expiry refreshes it while others keep getting the old value. Rails’ race_condition_ttl does a version of this.
  • Probabilistic early expiration: each read has a small, growing chance of refreshing the value before it expires, spreading recomputation out.
  • TTL jitter: add randomness to TTLs so keys written together don’t all expire together.
  • Warm critical keys after deploys or flushes, from a background job, before traffic finds them cold.

Redis in practice

  • Set maxmemory and an eviction policy. For a pure cache, allkeys-lru or allkeys-lfu. The default noeviction makes writes fail when memory is full.
  • Don’t share one Redis between cache and queues. Sidekiq, Celery or session data on an instance with eviction enabled will lose jobs. Use separate instances – maxmemory-policy applies to the whole Redis server, so a separate logical database doesn’t protect queue data.
  • Watch hit rate, evictions and memory fragmentation. A hit rate that drops after a deploy usually means a key format changed.
  • Avoid big keys and KEYS commands. A multi-megabyte value or a KEYS * in production blocks the single-threaded command loop. Use SCAN.
  • Treat the cache as disposable. The app must work, slower, if Redis is empty or down. Set short client timeouts so a slow cache degrades gracefully instead of stalling requests.
  • Namespace keys by tenant in multi-tenant apps. A cache key without a tenant ID is a cross-tenant data leak waiting to happen – see multi-tenant SaaS architecture.

A sensible default for SaaS

Fingerprinted assets cached forever at the CDN. Public pages cached at the edge with s-maxage and stale-while-revalidate. Cache-aside in Redis for expensive, read-heavy computations, with versioned keys, a TTL on everything, jitter, and a lock around recomputation for hot keys. Nothing cached that you can’t explain how to invalidate.

If your app is slow and you’re not sure whether caching is the answer, that diagnosis is a good use of a two-week sprint – usually as part of SaaS consulting or performance work for a growing SaaS.

Questions

What is the best caching strategy?

For most web applications: HTTP caching at the CDN for assets and public pages, plus cache-aside in Redis for expensive, frequently read data, using versioned keys, a TTL on every entry and protection against stampedes. Fix slow queries first, so the cache is an optimization rather than a crutch.

What is the difference between cache-aside and write-through?

With cache-aside, the application loads data into the cache only on a miss and deletes keys on writes. With write-through, every write updates the cache and database together, keeping the cache warm. Cache-aside is simpler and caches only what’s read; write-through suits data read immediately after writing.

What is a cache stampede?

When a popular cache entry expires and many requests miss simultaneously, each recomputing the same value and overloading the database. Prevent it with a lock so only one request recomputes, serving stale data during refresh, probabilistic early expiration and random jitter on TTLs.

How long should a cache TTL be?

As long as the data can safely be stale. Fingerprinted assets can be cached for a year. Dashboards often tolerate a minute. Permission and billing data should be short or explicitly invalidated. Always set some TTL, even with explicit invalidation, so a missed invalidation path eventually corrects itself.

App slow and not sure caching is the fix?

I’ll profile the slow paths with your team in a two-week sprint, fix the real bottleneck, and add caching only where it earns its complexity.

GMT+2 · Full EU overlap · US East mornings

Related