Skip to content
Tech Stack13 June 2026 · 7 min readUpdated 30 September 2026

Caching Strategy for SaaS: Redis Patterns That Scale

A practical Redis caching playbook for SaaS: cache-aside vs write-through, per-tenant key namespacing, TTL and eviction choices, and the invalidation traps that cause stale-data bugs.

Caching Strategy for SaaS: Redis Patterns That Scale

What you'll learn

  • When does a SaaS actually need Redis caching?
  • How does cache-aside compare with write-through?
  • How do you namespace cache keys in a multi-tenant SaaS?
Article contents

If a dashboard in your SaaS feels slow and you are about to add Redis, the first action is to measure before you cache: find the one query that is both expensive and read far more often than it changes, and time it. A cache in front of that query is a good trade. A cache in front of everything is a second source of truth you now have to keep consistent, and in a multi-tenant product the classic failure is serving one customer's cached data to another.

This post is a supporting deep-dive for the SaaS backend infrastructure guide. It covers when a cache earns its place, the cache-aside pattern, tenant-safe key names, and the TTL and eviction settings that keep a cache correct. For provider choice and pricing, see Redis, Upstash and Dragonfly for SaaS caching.

When does a SaaS actually need Redis caching?

A SaaS needs Redis caching when a measured read is slow, is requested far more often than its data changes, or hits a cost or quota ceiling on a downstream service, and not before.

A clean query with the right index serves a small product fine. Speed matters, though. web.dev treats a Largest Contentful Paint of 2.5 seconds or less as good and more than 4.0 seconds as poor, measured at the 75th percentile of page loads, so a dashboard that waits seconds on an aggregate is a real user-experience cost. That threshold is about page loads rather than your database, so use it as a yardstick for what slow means, not as proof that a cache will fix a given page.

Once you have a cache, measure it. Redis documents that INFO stats exposes keyspace_hits and keyspace_misses, and that the share of accesses served from the cache is keyspace_hits / (keyspace_hits + keyspace_misses) * 100. A sudden drop in that number usually points to an invalidation bug or a deploy that changed your key shapes.

Consider a hypothetical dashboard widget that re-runs an aggregate over a tenant's whole appointment history on every page view. That is a textbook candidate: the cost is nameable in milliseconds, and the freshness you can tolerate is nameable in seconds. A row that you read once and write often is not.

How does cache-aside compare with write-through?

Cache-aside lets the application populate the cache lazily on a miss, while write-through updates the cache on every write; for most SaaS the pragmatic default is cache-aside plus deleting affected keys on write.

Cache-asideWrite-through
Who fills the cacheThe app, on the first read after a missThe app, on every write
Read latencyFirst read after a miss is slowerReads are usually warm
Write latencyUnchanged (write goes to the database)Higher (database and cache both updated)
RiskStampede on a hot key; stale data if you forget to invalidateCaching data nobody reads again
Good fitDashboards, aggregates, permission lookupsA few always-hot keys such as plan config

The flow for cache-aside is four steps: ask Redis for the key, return it on a hit, query the database on a miss, then write the result to Redis with a TTL and return it. Redis's SET command accepts an EX option that sets the expiry in seconds, so the write and the TTL happen in one call.

async function getTenantDashboard(tenantId) {
  const key = `tenant:${tenantId}:v1:dashboard`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const data = await db.computeDashboard(tenantId); // expensive
  await redis.set(key, JSON.stringify(data), 'EX', 60);
  return data;
}

Cache-aside also degrades gracefully: if Redis is unreachable, your code can still read from the database, slower but correct, as long as you catch the cache error instead of failing the request.

How should you invalidate on write?

On a write, delete the affected keys and let the next read repopulate them, rather than rewriting the cache yourself. The trap is partial invalidation: you update a row, delete tenant:x:v1:user:5, and forget the tenant:x:v1:dashboard aggregate that also counted that row. Keep an explicit map of which writes invalidate which keys next to your write paths, so adding a cached view forces you to register its invalidation. Two defences help. The first is that map. The second is the version-segment trick for derived keys you cannot enumerate cleanly: bump the version on deploy and every old entry becomes unreachable at once. Between them, a forgotten key can only stay wrong until its TTL runs out, which is the property you want from a cache. Your tenant boundary defines your key prefix, so settle tenancy first; multi-tenant SaaS architecture covers the models.

How do you namespace cache keys in a multi-tenant SaaS?

Put the tenant ID at the front of every key, always, so two tenants can never collide and one tenant's cache can be dropped without touching anyone else.

bad:   user:42:profile             // which tenant's user 42?
good:  tenant:acme:v1:user:42:profile

A flat user:42 key works in a single-tenant app and becomes a data-leak bug when tenants have overlapping internal IDs, or when a logic error lets a request cross a tenant boundary. Three conventions pay off.

  1. Tenant first: tenant:{id}:..., so a tenant's keys sort together and can be found by prefix.
  2. A schema-version segment such as v1 for any cached shape that can change. Bumping it after a deploy makes every old entry unreachable without a single delete, and the TTL then clears them out.
  3. Human-readable keys, because you will read them during an incident.

To drop one tenant's keys, iterate with SCAN and a MATCH pattern rather than a blocking full-keyspace call. Redis documents SCAN as iterating over key names with O(1) work per call, and because it is incremental, run it in a background job rather than a request.

The cache must mirror the isolation your database enforces. Callidus, the React + Firebase + Stripe Connect clinic SaaS I built solo, keeps data under tenants/{tenantId}/ in Firestore and carries tenantId and role in JWT claims. Firestore rules enforce that boundary. A cache layer placed in front of any tenant-scoped store has to reproduce it in its key design, or it quietly defeats it. The same case study shows a related decision: the booking widget's Service Worker uses a NetworkOnly strategy for Firebase and Stripe requests so those are never cached, a choice the case study ties to GDPR compliance. Not caching is sometimes the correct answer.

What TTL and eviction settings keep a cache correct?

Set a TTL on every entry, pick a memory limit with an eviction policy, and treat the TTL as the safety net for invalidation you forgot.

The TTL answers one question: if my invalidation logic has a bug, how long until it heals? These are my starting points, to adjust for your data rather than rules from a spec.

  • Feature flags and per-request config: tens of seconds, or invalidate on change with a short TTL backstop.
  • User-facing dashboards and counts: from tens of seconds up to a few minutes.
  • Slow-changing reference data such as plans or country lists: minutes to hours.
  • No expiry only when your invalidation is airtight and you have a memory budget.

One documented gotcha: per the Redis SET docs, a plain SET on an existing key discards its previous time to live unless you pass KEEPTTL. A refresh that forgets to pass EX again can turn an expiring key into one that lives forever.

What happens when Redis runs out of memory?

Redis's eviction documentation says maxmemory set to zero means no limit, which is the default on 64-bit systems. Once you set a limit, maxmemory-policy decides what happens. noeviction returns errors on commands that would add data, allkeys-lru evicts the least recently used keys and is described as a good default when you have no reason to prefer another, and the volatile-* policies only consider keys that have a TTL, behaving like noeviction if none do. The same page notes that setting an expiry costs memory, so allkeys-lru is more memory efficient because it does not need one to work. Pick the policy on purpose, and watch evicted_keys and expired_keys in INFO to tell whether keys vanish because of memory pressure or an overly short TTL.

How do you prevent a cache stampede?

A stampede happens when a hot key expires and many concurrent requests all miss and hit the database together. The simplest mitigation is a short per-key lock so one request recomputes while the others briefly wait or serve the slightly stale value. Vercel's cron documentation points to Redis distributed locks for preventing concurrent runs, and the Redis SET page describes SET resource-name anystring NX EX max-lock-time as a simple lock while noting it is discouraged in favour of Redlock for stronger guarantees. For moderate scale, adding a little random jitter to your TTLs also spreads expiries apart. A scheduled job can warm the cache before peak hours; for running that job reliably, see background jobs with Inngest, Trigger.dev or custom.

There is no universal cache configuration; there is only the question of what a read costs and how stale it may be. If you are still deciding how your data is split per customer before you pick keys, revisit the tenancy model first.

Which read in your product costs the most and changes the least? Time it, write down how many seconds of staleness your users would accept, and start your cache with that single key.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

When should I add Redis caching to my SaaS?

Add Redis caching when you have a measured problem, not before. Typical triggers are a query that is genuinely slow, such as an aggregate or join, data that is read far more often than it changes, or a downstream service with a cost or rate ceiling. Before that, a well-indexed database query is simpler and keeps a single source of truth. Once a cache exists, track its hit rate from INFO stats, which Redis documents as keyspace_hits divided by hits plus misses, so you can tell whether it is working.

Cache-aside or write-through: which should I use for a SaaS?

Cache-aside is the default for most SaaS products. The app reads from the cache, falls back to the database on a miss and writes the result back with a TTL, so the cache only holds data someone requested. On writes, delete the affected keys and let the next read repopulate them. Reserve write-through for a small set of always-hot keys, such as plan configuration read on nearly every request, where the extra write latency is acceptable.

How do I keep cached data isolated between tenants?

Put the tenant ID at the front of every cache key, for example tenant:{id}:v1:dashboard, so two tenants can never collide. A flat key such as user:42 is how cached data leaks across tenant boundaries. The cache must mirror the isolation your database enforces: Callidus, for instance, keeps data under tenants/{tenantId}/ in Firestore and carries tenantId in JWT claims. To drop one tenant's entries, iterate with SCAN and a MATCH pattern in a background job rather than a blocking call.

What TTL should I set on cache entries?

Set a TTL on every entry, chosen by how stale the data may safely be. As starting points, use tens of seconds for feature flags and per-request config, up to a few minutes for dashboards and counts, and minutes to hours for slow-changing reference data. The TTL is your safety net for an invalidation you forgot, because it bounds how long wrong data can live. Note that Redis documents a plain SET as discarding an existing TTL unless you pass KEEPTTL, so refreshes must set the expiry again.

Why does my cache still show old data after an update?

The usual cause is partial invalidation: you deleted the obvious key on write but missed a derived or aggregate key that also depended on the changed data, such as a dashboard count that included the updated user. Keep an explicit map of which writes invalidate which keys. For aggregates you cannot enumerate, put a version segment in the key, such as v1, and bump it on deploy so old entries become unreachable at once while the TTL cleans them up.