Skip to main content
EN
Home / Three-layer caching for multi-tenant SaaS
Jan 15, 2026 · Z-SOFT Admin · 10 min read

Three-layer caching for multi-tenant SaaS

How CDN, Redis and versioned keys cut load without leaking one tenant's data into another's.

Back to all posts

In brief: Cache public data early, keep private data scoped to each tenant, and make invalidation explicit.

The problem

Adding Redis does not solve caching on its own. When a key expires, a busy route can still bury the database in duplicate work. And a single misconfigured shared-cache header can hand one tenant's private data to another.

Why the simple answer is not enough

A cache is just another distributed copy of your data. Once a response can live in a browser, at an edge node and in Redis at the same time, the system has to answer three questions for every copy: which one may be shared, how long it may go stale, and which event makes it invalid.

In a multi-tenant system, correctness includes isolation. A high hit rate is a failure, not an optimisation, the moment the key, a response header or an invalidation event lets tenant A's data be reused for tenant B.

System flow

Browser
local response cache
CDN
public responses only
Redis
shared application cache
Database
source of truth
Each layer removes repeat work, but every private cache key must still carry the tenant boundary.

Architecture decisions

Separate public and private paths

Only anonymous responses that are identical for everyone belong in a shared CDN. Authenticated responses use private or no-store directives, unless the cache key is explicitly varied by every identity dimension that matters.

Prevent cache stampedes

When a hot key expires, guard the rebuild with a short distributed lock or a single-flight promise. Other requests can serve a bounded stale value or wait briefly. What they must not do is all rebuild the same value against the database at once.

Prefer versioned invalidation

Deleting every related key is fragile and easy to get wrong. Instead, keep a compact data version per tenant or aggregate and fold it into the key. A committed write bumps the version, which makes old entries unreachable without ever scanning Redis.

Going deeper

Consistency is a product decision

TTL is not just an infrastructure parameter. Product owners have to decide which fields may go stale, and for how long. A public catalogue can often tolerate minutes. Permissions, billing status and feature entitlements usually need immediate invalidation, or no cache at all. Write the consistency class next to each cached query. For mutable aggregates, commit the business change and its invalidation event in the same database transaction, through an outbox. A relay can then retry publication until both Redis and the edge purge succeed. That closes the dangerous gap where the data changed but the cache was never told.

Control hot keys and cache cardinality

Tenant scoping can explode into millions of keys once filters, pagination and locale get folded in blindly. Normalise query parameters, hash only the dimensions you have approved, and cap the number of cacheable variants. Large tenants can also create hot keys that overload a single Redis shard, even with a healthy hit rate. When the numbers justify it, replicate immutable values, split aggregates into smaller keys, or add a short process-local cache. Track encoded value size and eviction policy as well: a cache under memory pressure can evict useful keys continuously and end up generating more database load than running without it.

Implementation path

  1. Classify each response as public or private before you decide where it may be cached.
  2. Use single-flight in Redis so that one request rebuilds an expired value while the rest wait.
  3. Put the tenant and a data version in the key, and bump the version whenever related data changes.

Technical example: Tenant-scoped read with stale-while-revalidate

key = `catalog:${tenantId}:v${catalogVersion}:${queryHash}`
cached = await redis.get(key)
if (cached && cached.age < freshFor) return cached.value

lock = await redis.set(`${key}:lock`, requestId, { NX: true, PX: 3000 })
if (!lock && cached && cached.age < staleFor) return cached.value

value = await db.catalog.findMany({ where: { tenantId } })
await redis.set(key, encode(value), { EX: staleFor })
return value

In real code the lock needs an owner token and a safe release. Add jitter to your TTLs too, so that thousands of keys do not all expire in the same second.

Failure modes to design for

  • A missing tenant ID produces a globally shared key. Fail the request rather than substituting an empty or default tenant.
  • The database transaction commits but the version invalidation fails. Publish the invalidation through an outbox so it can be retried reliably.
  • Redis goes down. Decide per route in advance: either bypass the cache with bounded database concurrency, or return a controlled degraded response.

What to observe

SignalWhy it matters
Hit ratio by route and tenantA global average can hide one noisy tenant, or a route that never really benefits from caching.
Rebuild count and lock waitSpikes point to stampedes, an undersized TTL or slow cache-fill queries.
Database queries per requestConfirms that cache hits actually remove downstream work, rather than just shifting it elsewhere.
Stale responses and invalidation lagShows the correctness cost you are paying for availability and faster reads.

How to validate the design

  • Run the same request concurrently for two tenants and prove that response bodies, ETags and cache keys never cross.
  • Expire a hot key under load and confirm the database rebuild count stays at one, or another value you have explicitly bounded.
  • Commit a write while the invalidation relay is stopped, restart it, and verify that the new version eventually becomes visible.
  • Disable Redis and measure whether your database concurrency limits keep the fallback path inside its latency objective.
  • Test public responses through a real CDN configuration, including Vary, Cookie and Authorization behaviour.

Safe rollout plan

Start with one read-heavy, low-risk route, plus dashboards for hit ratio, stale age, Redis latency and database queries. Run in shadow mode first: compute the keys and compare cached values, but do not serve them. Then enable a small tenant cohort, keep a per-route kill switch, and raise traffic only after your invalidation and fallback drills pass. Never ship CDN, Redis and a new key scheme in the same release. Separate stages keep regressions attributable and rollback straightforward.

Production checklist

  • Faster reads always come with an explicit window in which data may be stale.
  • Test headers and keys with two tenants, not just one happy path.
  • Watch hit rate, cache rebuilds and database queries together.

Takeaway

A safe cache design is not only fast. It makes ownership, lifetime and invalidation easy to see.

Your strategic technology partner

Turn your business challenge into a clear roadmap.

Talk directly with the Z-SOFT team about your goals, your current situation and the best next step.

Book a consultation