The Runtime Theory
Backend Engineering

Caching Strategies at the Service Layer: Redis, Local Cache, and Cache-Aside

Service-layer caching compared: cache-aside, write-through, TTLs, invalidation, stampede control, and Redis vs in-process cache tradeoffs.

The Runtime Theory Team4 min read#caching#redis#cache-aside#ttl#performance
On this page

A cache is not a speed optimization. It is a load-shifting mechanism: it converts a proportional cost (every read hits the database) into a fixed cost (every read hits memory, database hit only on miss). Done right, it turns a 95th-percentile database read of 40ms into a 95th-percentile cache read of 1ms. Done wrong, it serves stale data indefinitely and falls over in a stampede exactly when the database is already struggling. This article covers the strategies at the service layer and the mechanisms that make each one safe.

Cache-aside: the default strategy

Cache-aside (lazy loading) is the pattern behind virtually every cache in production: read the cache, and on a miss, read the database and populate the cache.

python
def get_order(order_id: int) -> Order:
    cached = redis.get(f"order:{order_id}")
    if cached is not None:
        return deserialize(cached)
    order = db.fetch_order(order_id)
    if order is not None:
        redis.set(f"order:{order_id}", serialize(order), ex=300)
    return order

Mechanically, three properties make this work: the cache is a pass-through (the database remains the source of truth), population happens on read (hot items get cached; cold items never waste memory), and eviction is by TTL (stale data expires even if nothing invalidates it).

The failure mode is the classic one: a write updates the database but the cache holds the old value until the TTL expires. Two mitigation mechanisms dominate. Invalidate on write — delete the key in the same transaction path as the write — keeps read-after-write consistent:

python
def update_order(order_id: int, changes: dict) -> Order:
    order = db.update_order(order_id, changes)
    redis.delete(f"order:{order_id}")   # invalidate, don't update
    return order

Note the mechanism: invalidate, don't update. Writing the new value into the cache is a race — two concurrent writers can leave the cache holding the older of two values. Deleting the key makes the next reader repopulate from the database, which is always correct.

TTLs: the only safety net

TTL is the mechanism that bounds staleness. It converts "stale forever" into "stale for at most N seconds". Every cached entry with a business-critical interpretation needs a TTL chosen against the data's actual freshness requirement: order status tolerates seconds; a config flag tolerates minutes; a rate-limit counter tolerates sub-second. Two rules: never cache unbounded (a long-TTL key with no invalidation path is a memory leak with a stale bug attached), and jitter the TTL — entries set with the same TTL expire at the same instant, concentrating misses into a single spike.

python
import random
ttl = 300 + random.uniform(-30, 30)   # jittered TTL
redis.set(key, value, ex=ttl)

Thundering herd and stampede control

The most dangerous mechanism is the miss stampede. A hot key expires; the next 500 concurrent requests all miss; all 500 execute the same database query simultaneously — exactly when the database's load spike was the reason you added a cache. The mechanisms that prevent it:

  • Single-flight (request coalescing): only one request fetches on a miss; the rest wait on the same future. Done in-process with a per-key lock (Go's singleflight.Group), or distributed with Redis's SET NX lock:
go
// singleflight: one DB query per miss, not one per request
result, err, _ := group.Do("order:"+orderID, func() (any, error) {
    return db.FetchOrder(orderID)
})
  • Early recompute (probabilistic expiry): refresh the cache while it's still valid, when the TTL is close to expiring, instead of after it's gone. Each hit with under 10% of TTL remaining triggers a background refresh — with jitter, refreshes are spread, not burst.
  • Stale-if-error: on a miss where the database is failing, serve the stale value rather than failing. Serves latency and availability at the cost of brief staleness.

Local cache vs Redis: the memory topology

A service-layer cache can live in two places, and the choice is about consistency scope. An in-process cache (Go map + mutex, Caffeine, LRU) is nanoseconds to read, but it is per-instance: N instances hold N copies, and an invalidation on one instance doesn't reach the others. Distributed caches (Redis, Memcached) are milliseconds to read but hold one shared copy, so invalidation is globally visible — at the cost of a network hop per read and a single extra failure domain.

The standard split: hot, per-request, non-critical data in-process (with a short TTL); shared, cross-instance-consistent data in Redis. The layered pattern — L1 local + L2 Redis — gets both, but invalidation is strictly harder (L1 copies go stale until their own TTL), so layering is for data that tolerates seconds of staleness.

Caching at the wrong layer

Most cache bugs are placement bugs. Caching computed responses (serialized JSON, rendered pages) means every schema change or permission tweak requires a coordinated invalidation sweep. The safer mechanism is to cache derived data with an identity — the database row, the aggregate value, the permission set — keyed by what it represents, not how it was rendered. And cache-aside is only worth it when the read path is measurably hot: below roughly 100 reads/sec per key, the cache's own overhead often costs more than the queries it saves. Measure the database's actual p99 before adding a cache; if the query is already 2ms, the cache is moving the problem, not solving it.

The checklist

  • Cache-aside on read; invalidate (never update) on write; TTL on everything.
  • Jittered TTLs; single-flight or early recompute on hot keys.
  • Redis for shared state, local cache for per-instance hot data, layered only when staleness is acceptable.
  • Cache errors are cache misses, never request failures.
  • Cache only what you've measured to be hot; the database is the source of truth.