Redis Caching for SaaS APIs: Invalidation, Tenancy, and Stampede Defense
A practical guide to Redis caching for SaaS APIs: cache-aside vs write-through, tenant-safe key design, invalidation that holds up after deploys, and stampede defenses with TTL jitter and single-flight.

Adding Redis in front of a slow endpoint is one of the quickest performance wins a SaaS team can ship. It's also one of the quickest ways to create bugs nobody can reproduce: a customer sees last week's price, a dashboard shows numbers from before an import, or, worst of all, one tenant briefly sees another tenant's data. None of these are Redis bugs. They come from design decisions made in the first afternoon of adding a cache, usually without anyone noticing a decision was being made.
This guide covers the parts of Redis caching for SaaS APIs that matter once you have real tenants and real traffic: picking a caching pattern, designing keys so tenancy is built into the key itself, invalidating without guesswork, and keeping a popular key's expiry from flooding your database. The examples use Python and redis-py, but the patterns carry over to any stack.

Three ways a SaaS cache goes wrong
Before choosing patterns, it helps to name the failures you're designing against. In multi-tenant APIs they almost always fall into three groups.
- Stale data. The database changed, but the cache still holds the old value and keeps serving it until a TTL runs out. Sometimes the TTL is hours. Sometimes there isn't one.
- Stampedes. A heavily read key expires, and every worker that misses at that moment runs the same expensive query at the same time. The cache was hiding a load problem, and expiry exposes it all at once.
- Tenant leaks. A key like
dashboard:summaryoruser:42gets written without a tenant in it. Two tenants with the same internal ID, or an endpoint that forgot to scope, now share a cache entry.
The first two cost you performance and correctness. The third is a security incident. Your key design should make the third close to impossible, and your invalidation and expiry strategy should keep the first two under control.
Cache-aside vs write-through: choose who writes to Redis
Most application caching follows one of two patterns. The difference is which code path puts data into Redis.

Cache-aside (lazy loading)
The application checks Redis first. On a miss it reads from the database, writes the result to Redis with a TTL, and returns it. Writes go to the database, and the application then deletes the related cache keys. Microsoft's Cache-Aside pattern guidance describes the same flow and its consistency trade-offs.
import json, random
import redis
r = redis.Redis(host="cache", port=6379, decode_responses=True)
def get_invoice(tenant_id: int, invoice_id: int) -> dict:
key = f"api:{{t{tenant_id}}}:v3:invoice:{invoice_id}"
cached = r.get(key)
if cached is not None:
return json.loads(cached)
row = db.fetch_invoice(tenant_id=tenant_id, invoice_id=invoice_id)
ttl = int(300 * random.uniform(0.9, 1.1)) # jittered TTL, see below
r.set(key, json.dumps(row), ex=ttl)
return row
Cache-aside's strengths are that only data someone actually reads takes memory, and a cold or flushed cache degrades to slower responses rather than errors. Its weakness is a race: a reader can miss, load the old row, get paused, and write that old row back after a writer has already updated the database and deleted the key. TTLs limit how long that stale value can live, which is one reason every cache-aside key should have one.
Write-through
Every write updates the database and the cache in the same request, so reads for recently written records almost always hit. You pay for it with higher write latency, memory spent on records nobody reads, and a harder failure case: if the database commit succeeds but the cache write fails (or the other way round), you need a repair path. In practice, write-through works best for a small set of read-heavy records with simple shapes, such as tenant settings or feature flags, rather than as the default for everything.
A reasonable default for most SaaS APIs is cache-aside with delete-on-write, plus write-through for a few hot configuration objects where a miss would be noticeably expensive.
Key design: tenant, version, entity, ID
If you get one thing right, make it the key format. A good key encodes everything that determines the cached value, so two requests that should see different data can never land on the same key.

A key format that holds up
We use a structure like api:{t42}:v3:invoice:9917:
- App prefix (
api) separates services that share an instance and makes it easy to tell where a key came from. - Tenant ID (
{t42}) goes in every key, with no exceptions. Build keys through a single helper that requires a tenant argument, so a handler can't forget it. - Schema version (
v3) changes whenever the cached payload's shape changes. Bumping it on deploy means new code never reads a payload the old code serialized. - Entity and ID (
invoice:9917) identify the record. For list or query results, add a stable hash of the normalized query parameters, and include anything that changes the output, such as locale, currency, or the caller's permission scope.
The curly braces aren't decoration. In Redis Cluster, a hash tag means only the part inside {...} is hashed to choose a slot, so all of a tenant's keys land on the same slot. That lets multi-key commands like UNLINK on several keys, or a Lua script, work on one tenant's data without CROSSSLOT errors. Microsoft's multitenancy guidance for Azure Managed Redis recommends the same convention. The trade-off is that one very large tenant becomes one hot slot, so for your biggest tenants you may want to hash-tag at a finer level, such as tenant plus entity.
Shared instance or one per tenant?
A shared instance with tenant-prefixed keys is cheap and simple, but your application is responsible for isolation, and tenants compete for memory and CPU. Dedicated instances give strong data and performance isolation at a much higher cost. Most B2B SaaS products start shared and move only regulated or very large tenants to their own instance. It's the same pool/silo decision we cover in our guide to multi-tenant SaaS architecture and tenant isolation, applied to the cache.
Redis cache invalidation that doesn't depend on luck
The saying that cache invalidation is one of the two hard problems in computer science lasts because people try to invalidate precisely, after the fact, from wherever the write happened. Three techniques cover nearly every SaaS case. Use the simplest one that works for each kind of data.
1. Delete on write, after commit
When a record changes, delete its key. Two details matter. First, delete after the database transaction commits: if you delete first, a concurrent reader can reload the pre-commit row and cache it again. In Django that means transaction.on_commit, and most frameworks have an equivalent. Second, delete instead of overwriting. Deleting is idempotent and avoids two writers racing to set different values.
from django.db import transaction
def update_invoice(tenant_id, invoice_id, changes):
with transaction.atomic():
db.update_invoice(tenant_id, invoice_id, changes)
transaction.on_commit(
lambda: r.unlink(invoice_key(tenant_id, invoice_id))
)
UNLINK removes the key immediately and reclaims memory in the background, which matters when cached values are large.
2. Version bump (generation keys)
Some writes invalidate a whole family of keys: every cached product listing for a tenant after a catalog import, or every report after a settings change. Hunting those keys down is slow and fragile. Instead, keep a generation counter per tenant and scope, and include it in the key:
def catalog_key(tenant_id, query_hash):
gen = r.get(f"api:{{t{tenant_id}}}:gen:catalog") or "0"
return f"api:{{t{tenant_id}}}:v3:catalog:g{gen}:{query_hash}"
def invalidate_catalog(tenant_id):
r.incr(f"api:{{t{tenant_id}}}:gen:catalog")
One INCR makes every old catalog key unreachable at once. Those old keys still use memory until they expire, so this only works if every key has a TTL. It costs one extra small read per request, which you can pipeline with the main lookup or keep in process memory for a second or two.
3. Tag sets for many-to-many dependencies
When one record feeds many cached responses (a product that appears in search results, bundles, and a storefront page), track that dependency explicitly. When you cache a response, add its key to a set for each record it depends on, using Redis sets. On write, read the set's members, unlink them, and unlink the set. Give tag sets a TTL as well, and keep them within the tenant's hash tag so the multi-key unlink stays on one slot. Tags are more precise than generation keys but add bookkeeping on every cache write, so save them for the dependencies that actually need them.
Offboarding and bulk cleanup
When a tenant leaves, iterate their prefix with SCAN and a MATCH pattern, then unlink in batches. Never use KEYS on a production instance. The Redis docs warn that it can block the server while it walks the whole keyspace.
Redis stampede defense: TTL jitter and single-flight
Stampedes happen when many requests miss the same key at the same moment. Two habits prevent most of them.
TTL jitter
If you warm a thousand keys during a deploy and give each one exactly 300 seconds, they all expire together five minutes later. Add random jitter, say 10% either way as in the earlier example, so expirations spread out. It's one line of code, and it removes a whole class of synchronized load spikes.
Single-flight rebuilds
Jitter spreads expiry across keys, but it doesn't help when a single key is hot. For that, make sure only one worker rebuilds it. A short-lived lock built with SET with the NX and PX options does the job:
import time, uuid
RELEASE = r.register_script("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
""")
def get_with_single_flight(key, loader, ttl=300, lock_ms=5000):
val = r.get(key)
if val is not None:
return json.loads(val)
lock_key = key + ":lock"
token = uuid.uuid4().hex
if r.set(lock_key, token, nx=True, px=lock_ms):
try:
data = loader()
r.set(key, json.dumps(data), ex=int(ttl * random.uniform(0.9, 1.1)))
return data
finally:
RELEASE(keys=[lock_key], args=[token])
# Someone else is rebuilding: wait briefly, then fall back to the loader
for _ in range(10):
time.sleep(0.05)
val = r.get(key)
if val is not None:
return json.loads(val)
return loader()
The random token and compare-and-delete script ensure a worker only releases its own lock, even if the lock expired and another worker took it. The fallback at the end is deliberate: if the rebuild is slow, a few extra queries are better than requests hanging.
Serve stale while revalidating
For data where a few extra seconds of staleness is fine, such as dashboards, counts, and catalog pages, store a "soft expiry" timestamp inside the cached value and set the real Redis TTL well beyond it. A reader that finds a value past its soft expiry returns it immediately and tries to take the rebuild lock to refresh it in the background. Users never wait for the rebuild, and the database sees one refresh query.
What not to cache
Some data is cheap to cache and expensive to get wrong. We keep these out of the application cache, or cache them only with very deliberate rules:
- Authorization decisions. If you cache "can this user do X?", a revoked permission keeps working until the TTL ends. Check permissions against the source of truth, or keep the TTL very short and invalidate explicitly on role changes.
- Exact money and balances. Account balances, credit limits, and the totals you charge should come from the database within a transaction. Showing a cached estimate is fine. Charging against one isn't.
- Fast-changing transactional state. Stock at checkout, seat counts, and order status during fulfilment change faster than any reasonable TTL. Caching them just moves the race condition somewhere harder to see.
- Sensitive personal data you can't protect in memory. Data in Redis typically sits unencrypted in memory, and Microsoft's guidance notes this for Azure Managed Redis. Encrypt it at the application layer or leave it out.
Also remember that Redis as a cache and Redis as a primary store are different jobs. Rate-limit counters, sessions, and job queues can't be evicted safely. If your Celery broker shares an instance with an aggressively evicting cache, you will eventually lose tasks. We cover broker setup separately in our guide to Celery and Redis for Django background jobs. For the cache itself, set maxmemory and an eviction policy on purpose. The Redis eviction docs suggest allkeys-lru as a good default for cache workloads, and note that the volatile-* policies behave like noeviction if no keys have a TTL.
Common mistakes we see in SaaS codebases
- Keys built by string concatenation in each handler. Sooner or later one handler forgets the tenant. Use one key builder that requires a tenant ID.
- No TTL "because we invalidate on write." Some write path will eventually skip invalidation: a bulk import, an admin script, a direct SQL fix. TTLs are your safety net.
- Caching a database that's struggling for other reasons. If the real problem is connection exhaustion or missing indexes, a cache hides it until the first cold start. Fix those first, for example with PostgreSQL connection pooling for SaaS APIs, and then cache.
- Caching errors and empty results without thinking. Caching "not found" for a few seconds protects against repeated lookups for missing IDs, but caching a timeout or a 500 turns a short glitch into a long outage.
- Measuring nothing. Track hit ratio per key family, not only the instance-wide
keyspace_hitsandkeyspace_misses. A 95% overall hit rate can hide one endpoint that never hits. - Forgetting the payload schema. Changing a serializer without bumping the version segment means new code briefly parses old payloads during a deploy.
FAQ
Should every tenant have its own Redis database number?
Usually not. Numbered databases aren't supported in Redis Cluster, and they provide no access control or performance isolation. Tenant-prefixed keys in a shared instance, or a dedicated instance for tenants that need real isolation, are clearer choices.
How long should cache TTLs be?
Set the TTL to how long the business can tolerate stale data, not to what gives the best hit rate. Seconds for dashboards with live numbers, minutes for catalog and settings data, longer only for data that rarely changes and is invalidated explicitly. Always add jitter.
Is Redis client-side caching a replacement for this?
No, it's an additional layer. Client-side caching keeps a local copy in the application process and relies on Redis to send invalidation messages. It can remove network round trips for very hot keys, but you still need good key design and server-side invalidation underneath.
Can I cache responses per user instead of per tenant?
Yes, when output depends on the user's permissions or preferences, but include both tenant and user (or a permission-scope hash) in the key. Per-user caching multiplies memory use, so reserve it for expensive endpoints.
Cache-aside or write-through for a new SaaS product?
Start with cache-aside, delete-on-write after commit, and jittered TTLs. Add write-through for a few hot configuration objects, and generation keys or tags only when you have a real fan-out invalidation problem.
Need a second pair of eyes on your caching layer?
CodeSapient designs and builds SaaS backends and APIs, including multi-tenant data models, Redis caching, background jobs, and the database work underneath. If your cache is serving stale data, your database falls over when a hot key expires, or you're not sure your keys are tenant-safe, take a look at our custom SaaS and API development services or tell us what you're building. We'll review your setup and recommend practical next steps.
