A product page takes 400 ms to render because it runs six queries and calls a pricing service. Someone adds a cache, the page drops to 15 ms, and everyone is happy until a customer complains that the price they see differs from the price at checkout. The cache is serving data that was correct ten minutes ago. Now the question is no longer “how do we make it fast” but “when is the cached copy allowed to be wrong, and how do we make it right again”.

Caching is easy to add and hard to get right. The strategies below describe how data moves between your application, the cache and the source of truth. Choosing among them is really choosing a consistency, latency and complexity profile.

First, Decide What a Cache Is For

A cache stores a copy of data that is expensive to compute or fetch, so repeated requests are cheap. It trades correctness for speed: the copy can become stale. Before choosing a strategy, answer three questions.

  • How stale is acceptable? A blog post can be a minute old. An account balance cannot.
  • How read-heavy is the data? Caching pays off when reads vastly outnumber writes and the same keys repeat. Caching data that is read once is pure overhead.
  • What is the cost of a miss? If a miss triggers an expensive computation under load, you must worry about stampedes, covered below.

If you cannot state an acceptable staleness for a piece of data, you are not ready to cache it.

Cache-Aside (Lazy Loading)

The application owns the logic. On a read, check the cache; on a miss, load from the database, store in the cache, and return. On a write, update the database and remove the cached entry.

import Redis from "ioredis";

const redis = new Redis();
const TTL_SECONDS = 300;

export async function getProduct(id: string): Promise<Product> {
  const key = `product:${id}`;

  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as Product;

  const product = await db.products.findById(id);
  if (!product) throw new NotFoundError(`product ${id}`);

  await redis.set(key, JSON.stringify(product), "EX", TTL_SECONDS);
  return product;
}

export async function updateProduct(id: string, patch: Partial<Product>): Promise<void> {
  await db.products.update(id, patch);
  await redis.del(`product:${id}`); // next read repopulates
}

This is the most common pattern, and for good reason. Only data that is actually requested gets cached, the cache can fail without breaking the application (reads fall through to the database), and the cache is a separate concern from the data layer. Its weaknesses: the first request after a miss is slow, and there is a window of staleness if another process reads between the database write and the del.

Notice that on write we delete the entry rather than updating it. Deleting is safer because two concurrent writers updating the cache can finish in the wrong order and leave an old value behind.

Python equivalent with a small decorator:

import json
import redis

r = redis.Redis()

def cache_aside(prefix: str, ttl: int = 300):
    def decorator(loader):
        def wrapper(key: str):
            cache_key = f"{prefix}:{key}"
            hit = r.get(cache_key)
            if hit is not None:
                return json.loads(hit)
            value = loader(key)
            r.set(cache_key, json.dumps(value), ex=ttl)
            return value
        return wrapper
    return decorator

@cache_aside("user", ttl=120)
def load_user(user_id: str) -> dict:
    return db.fetch_user(user_id)

Read-Through

Read-through looks like cache-aside from the caller’s perspective, but the cache itself knows how to load missing data. The application only talks to the cache. This centralizes the loading logic and makes it easy to add features such as request coalescing in one place.

class ReadThroughCache<T> {
  constructor(
    private readonly redis: Redis,
    private readonly loader: (key: string) => Promise<T>,
    private readonly ttlSeconds: number,
  ) {}

  async get(key: string): Promise<T> {
    const hit = await this.redis.get(key);
    if (hit) return JSON.parse(hit) as T;

    const value = await this.loader(key);
    await this.redis.set(key, JSON.stringify(value), "EX", this.ttlSeconds);
    return value;
  }
}

const products = new ReadThroughCache<Product>(redis, (id) => db.products.findById(id), 300);
const product = await products.get("42");

Few stores do this natively; in practice it is a thin wrapper like the one above, and the behavior is the same as cache-aside with the logic encapsulated. It is a good place to apply the Decorator pattern around a repository, so callers do not know a cache exists.

Write-Through

On a write, the application writes to the cache and the cache synchronously writes to the database (or the application writes to both in one step). The cache is always up to date for anything written through it.

export async function saveProduct(product: Product): Promise<void> {
  await db.products.upsert(product);                     // source of truth first
  await redis.set(`product:${product.id}`, JSON.stringify(product), "EX", TTL_SECONDS);
}

Reads after writes are fast and consistent, because the fresh value is already cached. The costs are higher write latency (two stores per write) and cache pollution: you cache every write, including data nobody reads. If the cache write fails after the database write succeeds, the two disagree, so a TTL remains useful as a safety net. Write-through combines naturally with read-through or cache-aside for reads.

Write-Behind (Write-Back)

Writes go to the cache first and are flushed to the database asynchronously, in batches. Write latency drops sharply and the database sees fewer, larger writes. It suits high-volume, loss-tolerant data such as counters, view counts or telemetry.

const pending = new Map<string, number>();

export function recordView(articleId: string) {
  pending.set(articleId, (pending.get(articleId) ?? 0) + 1);
}

setInterval(async () => {
  if (pending.size === 0) return;
  const batch = [...pending.entries()];
  pending.clear();
  await db.articles.incrementViewsBatch(batch);
}, 5_000);

The risk is data loss: if the process crashes before a flush, the buffered writes vanish. Durable variants buffer in Redis or a queue rather than process memory, but you are still accepting a window in which the database is behind. Do not use write-behind for anything where losing a write matters, such as payments or orders.

Choosing Between Them

StrategyRead pathWrite pathBest for
Cache-asideApp checks cache, loads on missUpdate DB, delete keyGeneral purpose, read-heavy data
Read-throughCache loads on missUsually combined with anotherCentralizing load logic
Write-throughAlways warm for written dataWrite to DB and cache togetherRead-after-write consistency
Write-behindAlways warmCache first, flush laterHigh write volume, loss tolerant

When unsure, start with cache-aside plus a TTL. It is simple, safe on failure and easy to reason about.

The Invalidation Problem

There is a well-known joke that the two hard problems in computer science are cache invalidation and naming things. The difficulty is real: the cache and the database are two stores with no shared transaction, so keeping them aligned is a distributed-systems problem in miniature. Approaches, roughly from simplest to most precise:

Time-based expiry (TTL). Every entry expires after a fixed time. It is crude but robust: even if you miss an invalidation, the error is bounded by the TTL. Always set one, even when you also invalidate explicitly. Entries without a TTL are the ones that become permanent bugs.

Explicit invalidation on write. Delete or update the key whenever the underlying data changes. Precise, but you must find every code path that changes the data. A forgotten path leaves stale data until the TTL saves you. Centralize writes behind a repository or service so there is one place to invalidate. See the Repository pattern for that structure.

Versioned keys. Instead of deleting, change the key. Store a version number per entity or namespace and include it in cache keys; bumping the version makes every old entry unreachable, and TTL cleans them up later.

async function listKey(tenantId: string): Promise<string> {
  const version = (await redis.get(`tenant:${tenantId}:version`)) ?? "0";
  return `tenant:${tenantId}:products:v${version}`;
}

async function invalidateTenant(tenantId: string): Promise<void> {
  await redis.incr(`tenant:${tenantId}:version`);
}

This is excellent for invalidating a whole family of keys, such as every list and filter combination for a tenant, without scanning the keyspace. Avoid KEYS * or large pattern deletes in production; they block Redis.

Event-driven invalidation. Publish a change event when data is written and let subscribers purge their caches. It scales across services but adds ordering and delivery concerns; pair it with the outbox pattern if you cannot afford to lose an invalidation message.

Even the careful cache-aside flow has a race. Reader A misses and reads old data from the database; a writer updates the database and deletes the key; reader A then writes its old data into the cache. The stale entry now lives until its TTL. This race is rare, which is exactly why a TTL is your backstop. Deleting after the write rather than before it, and keeping TTLs short for volatile data, shrinks the window.

Cache Stampedes

When a popular key expires, many concurrent requests miss at once and all hit the database to rebuild it. A key serving thousands of requests per second can take a database down the moment it expires. This is a cache stampede, or thundering herd.

The first defense is request coalescing inside a process: concurrent callers for the same key share one in-flight load.

const inflight = new Map<string, Promise<unknown>>();

export async function coalesce<T>(key: string, loader: () => Promise<T>): Promise<T> {
  const existing = inflight.get(key);
  if (existing) return existing as Promise<T>;

  const promise = loader().finally(() => inflight.delete(key));
  inflight.set(key, promise);
  return promise;
}

const product = await coalesce(`product:${id}`, () => loadAndCache(id));

Across several servers, use a short distributed lock so only one rebuilds while others briefly wait or serve stale data:

async function getWithLock(key: string, loader: () => Promise<string>): Promise<string> {
  const hit = await redis.get(key);
  if (hit) return hit;

  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
  if (acquired) {
    try {
      const value = await loader();
      await redis.set(key, value, "EX", 300);
      return value;
    } finally {
      await redis.del(lockKey);
    }
  }
  await new Promise((r) => setTimeout(r, 50 + Math.random() * 50));
  return getWithLock(key, loader);
}

Two cheaper techniques help too. TTL jitter: add a random amount to each TTL so keys loaded together do not expire together. Stale-while-revalidate: keep serving the expired value while one request refreshes it in the background, trading a bit of staleness for stable latency.

Negative Caching and Cache Penetration

Requests for keys that do not exist (a deleted product, a mistyped id, or deliberate probing) miss the cache every time and always reach the database. Cache the absence too, with a short TTL:

const NOT_FOUND = "__none__";

const cached = await redis.get(key);
if (cached === NOT_FOUND) throw new NotFoundError(id);

const row = await db.products.findById(id);
if (!row) {
  await redis.set(key, NOT_FOUND, "EX", 30);
  throw new NotFoundError(id);
}

Keep the TTL short so a newly created record does not stay hidden for long.

Caching is sometimes used to paper over a query pattern that should be fixed instead. If your page is slow because of dozens of queries per request, an ORM trap such as the one in N+1 Queries may be the real cause, and fixing it is cheaper and safer than adding a cache that needs invalidation.

Common Mistakes

  • No TTL. Entries that never expire turn every missed invalidation into a permanent bug.
  • Caching before measuring. Add a cache to a proven hot path, not by reflex.
  • Caching per-user data under a shared key. Include the user or tenant in the key whenever the response varies by caller; otherwise one customer can see another’s data.
  • Unbounded key space. Keys built from free-form input fill memory. Normalize inputs and set eviction limits such as a max memory policy.
  • Treating the cache as a database. It can be evicted or lost at any time. The application must work, slowly, without it.
  • Serializing huge objects. Caching a 5 MB blob and parsing it on every hit can be slower than the query it replaced. Cache what you need.
  • Ignoring failure of the cache itself. Wrap cache calls so that a Redis outage degrades to database reads rather than errors, and watch the hit ratio so you notice when it collapses.

When Not to Cache

Do not cache data that must be strongly consistent, such as balances, inventory at checkout or authorization decisions that must reflect revocation instantly. Do not cache rarely repeated queries. Do not cache when the real cost is an unindexed query that a better index would fix. And be cautious when a cache adds a second source of truth that your team has no tooling to inspect and purge.

Key Takeaways

  • Choose a strategy by the staleness you can tolerate: cache-aside with TTL is the safe default, write-through for read-after-write, write-behind only for loss-tolerant writes.
  • Delete cache entries on write instead of updating them, and always set a TTL as a backstop.
  • Versioned keys invalidate whole groups of entries without scanning the keyspace.
  • Protect hot keys from stampedes with coalescing, locks, TTL jitter or stale-while-revalidate.
  • Cache the absence of data briefly to avoid penetration, and include caller identity in keys for per-user data.
  • Treat the cache as disposable: the system must stay correct when it is empty or down.