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-aside | Write-through | |
|---|---|---|
| Who fills the cache | The app, on the first read after a miss | The app, on every write |
| Read latency | First read after a miss is slower | Reads are usually warm |
| Write latency | Unchanged (write goes to the database) | Higher (database and cache both updated) |
| Risk | Stampede on a hot key; stale data if you forget to invalidate | Caching data nobody reads again |
| Good fit | Dashboards, aggregates, permission lookups | A 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.
- Tenant first:
tenant:{id}:..., so a tenant's keys sort together and can be found by prefix. - A schema-version segment such as
v1for 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. - 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.
