Three cache tiers in a monolith and the consistency each can offer
A monolith typically caches at three tiers. Each tier is wider in scope than the one before it, and its staleness comes from a different source. The strategy is to design invalidation from the write path outward. Every tier needs an explicit, bounded staleness window, not just whatever its TTL happens to be.
- Request-scoped memoization lives for one request or unit of work. It goes stale only when the same request writes and then re-reads a value it already memoized. Its window ends when the request ends.
- Process-local in-memory cache is shared by requests within one instance. Another instance's write cannot reach it unless you broadcast invalidations. Without broadcast, its window is its TTL.
- Shared Redis cache is visible to every instance. It goes stale through missed deletes and through races in which a slow reader repopulates an old value after a write.
The windows add up. A process-local entry filled from a stale Redis value can outlive the Redis entry by its own full TTL. Worst-case staleness is therefore the sum of both windows unless invalidation reliably reaches both tiers.
READ: request memo -> process cache -> Redis -> database
|
WRITE: commit to database --------------------------+
+-> clear request memo
+-> broadcast: evict process caches (all instances)
+-> DEL Redis key
| Tier | Scope | Lifetime | Consistency | Memory cost | Typical failure mode |
|---|---|---|---|---|---|
| Request memo | One request | Request duration | Read-your-writes if cleared on write | Negligible | Stale read after an in-request write |
| Process cache | One instance | TTL / LRU | Bounded by TTL or broadcast | Multiplied per instance | Instances disagree after a write |
| Redis | All instances | TTL / explicit delete | Bounded by delete reliability | Central, sized once | Stale repopulation race, stampede |
READ PATH: a miss falls through to the right; results fill tiers leftward
+-----------+ miss +-----------+ miss +-----------+ miss +----------+
| Request |------->| Process |------->| Redis |------->| Database |
| memo | | cache | | (shared) | | |
| 1 request | | 1 instance| | all inst. | | |
+-----------+ +-----------+ +-----------+ +----------+
^ ^ ^ |
| clear | broadcast evict | DEL key |
| memo | to all instances | |
| | | |
+-----+--------------------+--------------------+-----------+ |
| WRITE PATH: commit to DB, then fan out invalidation |<------+
+-----------------------------------------------------------+
Staleness window per tier:
request memo : ends when the request ends
process cache : its TTL, unless the broadcast evict reaches it
Redis : its TTL, after a missed DEL or a slow-reader refill
Worst case if invalidation misses a tier: process TTL + Redis window
Reads fall through request memo, process cache and Redis to the database, while each committed write fans invalidation back out to every tier so that each tier's staleness window stays explicit and bounded.
Who should use this strategy
This design fits teams that run a single deployable application as several identical instances behind a load balancer, with a relational database as the source of truth. It is most useful when the database, not application CPU, is what limits throughput or latency, and the same rows are read far more often than they are written.
The engineers who benefit most are those who own the data-access layer or a shared persistence module. They can put invalidation in one place instead of asking every feature team to remember it. Teams that run a single instance can usually skip the broadcast machinery, because a process-local cache with one copy has no cross-instance disagreement. Low-traffic applications whose database is nowhere near its limits should usually not adopt it at all, for the reasons given in "When not to cache" below.
When to use it, and in what order to add tiers
Reach for caching when all of the following are true:
- The query has already been indexed and tuned, and it is still a measurable share of request latency or database load.
- The same keys are read repeatedly, so a cached value is likely to be read again before it expires or is invalidated.
- You can state, per type of data, how stale a value may be before it causes a real problem.
Add the tiers incrementally rather than all at once:
- Request memo first. It is cheap and removes duplicate loads of the same entity within one request. It also introduces no cross-request consistency problem.
- Redis next, when repeated loads across requests and instances are the cost. It has one copy per key, so delete-on-write invalidation is straightforward.
- Process cache last, and only for hot, small, rarely changing data where avoiding the Redis round trip matters. This tier carries the hardest consistency problem.
How the strategy works end to end
On a read, the application checks the nearest tier first: the request memo, then the process cache, then Redis, and finally the database. Whatever a lower tier returns is written into the tiers above it on the way back, so the next read is served closer to the caller.
On a write, the database commit comes first. Only after a successful commit does the application invalidate outward. It clears the request memo, broadcasts an eviction to every instance's process cache, and deletes the Redis key. Invalidation means deleting entries, never overwriting them with the new value. Every entry in every tier carries a TTL, which caps staleness if any invalidation step is lost. Where stale data is harmful, a version check on populate prevents a slow reader from writing an old value back. The sections below cover each of these mechanisms in detail.
Redis cache-aside done right: delete on write, after commit
The read path is three steps. GET product:42; on a hit, return it. On a miss, load the row from the database, then SET product:42 <payload> EX 300 and return. The TTL is a backstop: if every other mechanism fails, staleness still ends when the key expires.
The write path reverses the order most people reach for first. Commit the database change, then DEL product:42. Do not overwrite the key with the new value. With set-on-write, two concurrent writers can commit in one order and reach Redis in the other, and the cache then holds the older value until the TTL expires. With delete-on-write, the cache holds nothing, and the next reader loads whatever the database currently holds. The remaining race is narrower: a reader that loaded before the write and populates after the delete. Section 3 covers that case.
The delete must follow the commit, not a statement inside the transaction. If you delete mid-transaction, a concurrent reader can repopulate from the old row before your commit lands. If the transaction then rolls back after a delete or set, the cache no longer reflects the database. Register the invalidation as an after-commit hook: during the transaction, call on_commit(() -> redis.DEL(key)), and the framework runs it only once the commit succeeds.
A hook still runs in process memory. If the process crashes or Redis is unreachable between the commit and the delete, the delete is lost. When that is unacceptable, write an outbox row naming the key in the same transaction as the data change. A relay process then issues DEL for each row and marks the row done, retrying until Redis confirms.
function get_product(id):
key = "product:" + id
cached = redis.GET(key) # GET product:42
if cached != null:
return deserialize(cached) # hit
row = db.query("SELECT * FROM products WHERE id = ?", id)
redis.SET(key, serialize(row), EX=300) # SET product:42 <payload> EX 300
return row
function update_price(id, price):
key = "product:" + id
tx = db.begin()
tx.execute("UPDATE products SET price = ? WHERE id = ?", price, id)
# Runs only if the commit succeeds; a rollback discards it.
tx.on_commit(() -> redis.DEL(key)) # DEL product:42
tx.commit()
function update_price_durable(id, price):
tx = db.begin()
tx.execute("UPDATE products SET price = ? WHERE id = ?", price, id)
tx.execute("INSERT INTO cache_outbox (cache_key, done) VALUES (?, false)",
"product:" + id) # commits or rolls back with the data
tx.commit()
loop forever:
rows = db.query("SELECT id, cache_key FROM cache_outbox WHERE done = false LIMIT 100")
for row in rows:
try:
redis.DEL(row.cache_key) # idempotent: deleting a missing key is fine
db.execute("UPDATE cache_outbox SET done = true WHERE id = ?", row.id)
catch RedisError:
continue # row stays pending; retried next pass
sleep(1s)
Pseudocode for the cache-aside read path, a delete-after-commit write path using an on_commit hook, and an outbox table with a relay that retries DEL until Redis confirms.
The stale-repopulation race and how far to go in closing it
Even with reliable deletes, cache-aside has a window in which stale data can win:
- Reader A misses the cache and reads row version 1 from the database.
- A stalls, for example on a GC pause or a slow network hop.
- Writer B commits version 2 and issues
DEL. - A resumes and writes version 1 into the cache.
The cache now holds old data that no later delete will remove, because the write has already happened. The race needs a miss, a slow reader and a concurrent write on the same key, so it is rare. When it does happen, it lasts until something evicts the entry.
| Mitigation | Guarantee | Cost | Best-fit data |
|---|---|---|---|
| Short TTL as backstop | Staleness is bounded by the TTL | More misses and more database load | Profiles, catalogue text, most reads |
| Version check on populate | An older version cannot overwrite a newer one | A version column, plus a server-side script for an atomic compare-and-set | Balances, permissions, inventory |
Versioned keys (item:42:v7) |
Readers never see an older version's key | An extra lookup to learn the current version, plus orphaned keys that need a TTL | Large, immutable-per-version blobs |
| Delayed second delete | Removes most late repopulations | A scheduled job, plus a delay you must tune above worst-case read latency | Moderate-risk data where version columns are impractical |
The TTL is always worth having because it caps the damage from any bug. The other three mitigations are only worth their cost when stale data is actually harmful. For low-risk data that stays fresh enough within a TTL of minutes or less, adding version columns or a scheduled second delete brings complexity and new failure modes for little benefit.
import redis
r = redis.Redis(host="cache.internal", port=6379, decode_responses=True)
TTL = 300 # backstop: staleness is bounded even if the logic below has a bug
populate = r.register_script("""
local cur = redis.call('HMGET', KEYS[1], 'v', 'data')
local nv = tonumber(ARGV[1])
if cur[1] then
local cv = tonumber(cur[1])
if nv < cv or (nv == cv and cur[2]) then return 0 end
end
redis.call('HSET', KEYS[1], 'v', ARGV[1], 'data', ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1
""")
invalidate = r.register_script("""
local cv = tonumber(redis.call('HGET', KEYS[1], 'v') or '-1')
if tonumber(ARGV[1]) < cv then return 0 end
redis.call('DEL', KEYS[1])
redis.call('HSET', KEYS[1], 'v', ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
return 1
""")
def read_item(item_id, load_row):
key = f"item:{item_id}"
data = r.hget(key, "data")
if data is not None:
return data
version, payload = load_row(item_id) # e.g. SELECT version, payload ...
populate(keys=[key], args=[version, payload, TTL]) # rejected if stale
return payload
def after_commit(item_id, new_version):
invalidate(keys=[f"item:{item_id}"], args=[new_version, TTL])
A version check on populate, implemented as Redis Lua scripts with a TTL backstop. The writer leaves a version tombstone instead of deleting the key, so a slow reader that loaded version 1 cannot write it back after version 2 has committed.
Keeping process-local caches honest across instances
Redis has one copy of each key, so deleting it fixes the value for every instance. A process-local cache has as many copies as there are running instances. A delete in Redis does not reach any of them, so in a horizontally scaled monolith, cross-instance staleness usually comes from the in-memory tier.
Only three kinds of data are safe to keep in process memory:
- Immutable data. Content-addressed blobs and historical records never change, so a cached copy is never stale.
- Versioned data. When the version is part of the key, a new version is a cache miss, not a stale hit.
- Data that tolerates a bounded TTL. Feature flags and reference tables qualify if a few seconds of drift does no harm.
| Mechanism | Delivery guarantee | Failure mode |
|---|---|---|
| Redis pub/sub broadcast | At-most-once | Instances that are disconnected or restarting miss the message and keep stale entries |
| Generation key checked against Redis | Each read compares its local copy with Redis's current generation (one round trip per read, or per check interval) | A Redis outage forces you to choose between serving unchecked data and bypassing the cache |
| Short TTL alone | Staleness bounded by the TTL, with no coordination | Data is stale for up to the full TTL after every write |
Pub/sub gives the lowest latency, but it is fire-and-forget. Generation checks are only as reliable as Redis's availability. Neither guarantees convergence by itself, so every entry still needs a TTL as the backstop that bounds the worst case.
Memory is the other cost. A 200 MB cache on 30 instances uses 6 GB of heap fleet-wide. Long-lived entries also survive into the old generation of the garbage collector, which makes major collections longer. After each deploy, every new instance starts with an empty cache and sends its misses to Redis and the database at the same moment.
import threading, time
import redis
r = redis.Redis(host="redis.internal", port=6379, decode_responses=True)
TTL_SECONDS = 30 # backstop: bounds staleness when every other signal fails
_local = {} # key -> (value, generation, expires_at)
def _subscriber():
# Pub/sub is at-most-once: anything published while this loop is
# disconnected or the process is restarting is simply lost.
while True:
try:
p = r.pubsub(ignore_subscribe_messages=True)
p.subscribe("cache-invalidate")
for msg in p.listen():
_local.pop(msg["data"], None)
except redis.ConnectionError:
time.sleep(1) # reconnect; missed messages are not replayed
threading.Thread(target=_subscriber, daemon=True).start()
def get(key, load):
now = time.monotonic()
try:
gen = r.get(f"gen:{key}") or "0" # one round trip per read
except redis.RedisError:
gen = None # Redis down: serve local copy, trusting only the TTL
entry = _local.get(key)
if entry and entry[2] > now and (gen is None or entry[1] == gen):
return entry[0]
value = load(key)
_local[key] = (value, gen or "0", now + TTL_SECONDS)
return value
def update(key, value, save):
save(key, value)
r.incr(f"gen:{key}") # durable signal: every instance sees the new generation
r.publish("cache-invalidate", key) # fast signal: evicts early, may be missed
A process-local cache that layers a fire-and-forget pub/sub subscriber and a per-read generation check against Redis on top of a TTL, which still caps staleness when messages are missed or Redis is unreachable.
Stampedes, key design and TTL policy
When a hot key expires, every request that misses at that moment recomputes it at once. Inside one process, collapse those misses with single-flight: the first caller loads the value, and concurrent callers for the same key wait for its result. Across instances, take a short-lived lock in Redis with SET lock:<key> <token> NX EX 5. The winner rebuilds the entry. The others briefly wait and re-read, or serve a stale copy if you keep one. The EX matters because a crashed holder's lock expires on its own instead of blocking the key. Add random jitter, for example ±10%, to TTLs. Otherwise entries written together during a cold start also expire together.
Key design. Namespace and version every key, as in app:user:v3:42. Bump the version whenever the serialized shape changes. Old and new instances running side by side during a deploy then read separate entries instead of failing to deserialize each other's.
TTL policy. Give every entry a TTL, including ones you invalidate explicitly, because some write path will eventually skip invalidation. Short TTLs limit staleness but add database load and miss latency. Long TTLs reverse that trade. Cache "not found" results only with a much shorter TTL, and delete them on insert. Otherwise a newly created row stays invisible until the negative entry expires.
Request-scoped pitfalls. A request cache assumes the request is short. A background job that reads an entity, writes it, then reads it again gets the pre-write copy unless the write also evicts the request-cache entry. In long jobs, scope the cache to each unit of work rather than to the whole job.
Which data goes in which tier, and when not to cache
| Data | Tier | Staleness bound | Why |
|---|---|---|---|
| Reference data (currencies, plans, feature config) | Process cache | Minutes, with broadcast eviction | Read on almost every request, changes rarely, small enough to hold per instance |
| Session or profile data | Redis cache-aside | Seconds; delete on write, TTL as backstop | Shared across instances, and a user notices their own stale edits |
| Computed aggregates (counts, dashboards) | Redis with TTL | The lag the product accepts, often 1–5 minutes | Expensive to compute and tolerant of lag |
| Permissions and roles | Request cache only | Zero across requests | Revoked access must take effect on the next request |
These bounds are starting points, not rules. Set each one from the business impact of a wrong answer.
When not to cache
Skip caching on authorization checks where a cached grant could keep a revoked user, token or API key working. Skip it for data that needs strict read-after-write consistency, such as balances or inventory at checkout. Also skip low-traffic data. Invalidation code there costs more to maintain than the queries it saves.
Redis on the read path
If reads fall back to the database when Redis is down, the database suddenly takes the full uncached load. That can turn a cache outage into a database outage. Either size the database to absorb that load or limit the fallback with a concurrency cap and shed the excess. Otherwise, treat Redis as a hard dependency and fail fast.
Before adding a cache tier, try the cheaper options: optimize the query, route reads to replicas, precompute results into materialized views or summary tables, or use HTTP or CDN caching for public responses.
Alternatives to a tiered application cache
Each alternative removes load from the database without adding application-managed invalidation. Each one also has its own trade-offs.
- Query and schema tuning. Adding indexes, removing N+1 query patterns and selecting only the columns you need often removes the problem the cache was meant to hide. Nothing can go stale, because nothing is copied.
- Read replicas. Routing reads to replicas spreads load across more database servers. Replication lag is a staleness window of its own, and you cannot bound it per key. Read-your-writes paths still need to go to the primary.
- Materialized views and summary tables. Expensive aggregates are precomputed inside the database and refreshed on a schedule or by triggers. Consistency stays a database concern, at the cost of refresh work and lag.
- HTTP and CDN caching. For public, non-personalized responses,
Cache-Controlheaders let downstream caches serve repeat requests before they reach the monolith. Invalidation is coarse, and it does not suit per-user data. - The database's own buffer cache. Giving the database enough memory to keep its working set in memory speeds up repeated reads with no change to application code. It does not reduce query parsing, planning or network round trips.
- A single shared tier only. Using Redis alone, without a process-local tier, avoids the cross-instance broadcast problem entirely. You pay a network round trip on every hit.
A reasonable order is to tune queries first, then try the database-side options, and add application tiers only for the hot paths that remain.
Observability, incident controls and the cost to build and run it
What to measure
Track hit and miss rates per tier, not as a single blended number. A healthy Redis hit rate can mask a process cache that never warms. Record the age of each entry when it is served (now minus write time, stored alongside the value), and alert on high percentiles rather than averages. For invalidation, count published versus applied messages per instance and measure the delay between the commit and the eviction. Rising lag usually shows up before users report stale pages.
Incident controls
Give each tier its own bypass flag that can be changed at runtime, so on-call engineers can rule a tier in or out without a deploy. Avoid running FLUSHALL under load. Instead, bump a namespace version in the key prefix one namespace at a time. Old keys expire naturally, and request coalescing limits how quickly the new keys refill.
Cost
Caching adds work: a second read path, race tests that need controlled interleavings, and more alerts for on-call. Writing one shared module that handles commit-hooked deletes, key conventions and default TTLs pays for itself because teams stop building one-off invalidation and stop debugging stale data case by case.
Illustrative estimate (assumed numbers, not a benchmark). Assume 2,000 reads/s and 50,000 hot keys.
- DB reads/s ≈ reads/s × (1 − hit rate). At a 90% hit rate this gives 200/s. At 99% it gives 20/s.
- The TTL sets a floor on refills: hot keys ÷ TTL. A 300 s TTL forces about 167 refills/s. A 3,600 s TTL forces about 14/s.
With a 300 s TTL, those refills alone hold the hit rate near 92%, so the TTL limits the best hit rate you can reach.
How it saves engineering time
Most of the time saved comes from making caching a shared, explicit contract instead of a per-feature decision:
- Invalidation written once. When the shared module registers after-commit deletes and broadcasts, feature code calls a write function and gets correct invalidation. Engineers do not have to rediscover the set-on-write and delete-before-commit races feature by feature.
- Fewer deploy incidents. Versioned key prefixes mean a change to the serialized shape does not need a coordinated cache flush or a cleanup step during rollout.
- Faster stale-data debugging. Each tier has a stated staleness bound, so "is this a bug?" becomes a check: is the observed age within the tier's bound? Per-tier hit rates, served-entry age and invalidation lag show which tier is at fault. Bypass flags let on-call confirm the cause without a deploy.
- Less plumbing in request code. The request memo removes the need to pass loaded entities through call chains to avoid reloading them, and it collapses duplicate loads within one request.
- Effort spent only where it matters. The data-to-tier table and the "when not to cache" rules stop teams from building version checks and outbox relays for data that a TTL already makes fresh enough.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith caching strategy, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.