Caching Strategies Every Backend Engineer Should Know

Caching is one of those topics that sounds simple until you ship it. Add a cache, serve a few requests from memory, watch your latency graph drop, and call it...

Originally published onanselmfowel.com

Caching is one of those topics that sounds simple until you ship it. Add a cache, serve a few requests from memory, watch your latency graph drop, and call it a day. Then a stale balance shows up in a customer statement, or a deploy floods your database the moment the cache empties, and you learn that caching is not a feature you bolt on but a distributed-systems problem you have chosen to take on.

Caching Strategies Every Backend Engineer Should Know
Caching Strategies Every Backend Engineer Should Know

I have spent most of my career building payment and ledger systems where correctness is non-negotiable and milliseconds still matter at scale. Over that time I have collected a set of caching strategies that are worth knowing not because they are clever, but because they fail in predictable ways once you understand them. This is the practical tour I wish someone had given me earlier.

Why We Cache, and Why It Is Not Free

The honest reason to cache is that the cheapest work is the work you never do. A read served from memory avoids a network hop, a query planner, a disk seek, and contention on a shared resource that everyone else also wants. In a fintech backend the database is almost always the scarcest, most expensive, and least horizontally scalable thing you own, so protecting it is usually the real goal of a cache, with raw latency a close second.

But a cache is a second copy of your data, and the moment you have two copies you have a consistency problem. Every caching strategy is fundamentally a negotiation about how wrong you are willing to be, for how long, and in exchange for what. If you cannot answer the question "what is the worst thing that happens when this value is stale?" then you are not ready to add the cache yet. For an account balance the answer might be "we never serve it from cache." For a merchant's display name the answer is "a few minutes of staleness is invisible."

Cache-Aside: The Default You Should Reach For First

Cache-aside, also called lazy loading, is the pattern most teams start with and the one I still reach for by default. The application owns the logic: on a read, you check the cache; on a miss, you load from the source of truth, populate the cache, and return the value. The cache never talks to the database itself, which keeps the moving parts visible and easy to reason about.

The strength of cache-aside is resilience. If the cache is down, your application degrades to reading straight from the database rather than failing outright. The weakness is the first-request penalty on every miss and the subtle race conditions around writes. Here is the shape I use in .NET, deliberately keeping the failure mode explicit.

public async Task<Merchant> GetMerchantAsync(Guid id)
{
    var key = $"merchant:{id}";
    var cached = await _cache.GetStringAsync(key);
    if (cached is not null)
    {
        return JsonSerializer.Deserialize<Merchant>(cached)!;
    }

    var merchant = await _repository.LoadAsync(id);
    if (merchant is null)
    {
        return null;
    }

    var options = new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
    };
    await _cache.SetStringAsync(key, JsonSerializer.Serialize(merchant), options);
    return merchant;
}

Notice the absolute expiration. I treat a TTL as a correctness backstop, not a performance tuning knob: even if every other invalidation path in my system has a bug, the cache cannot be wrong for longer than ten minutes. That single line has saved me from more production incidents than any amount of clever invalidation logic.

Write-Through and Write-Behind

Cache-aside leaves writes to the application, which means the cache can drift from the database between a write and the next read. Write-through closes that gap by routing writes through the cache, which synchronously updates both itself and the backing store. Reads after a write are then guaranteed fresh because the cache was updated as part of the write path. The cost is write latency: every write now pays for two systems instead of one.

Write-behind, sometimes called write-back, decouples the two by acknowledging the write to the cache immediately and flushing to the database asynchronously. This gives you very fast writes, but you have now made your cache a system of record for a window of time, and if the cache node dies before the flush you have lost data. In regulated finance I do not use write-behind for anything that touches money. I have used it for high-volume, low-stakes telemetry like rate-limit counters and feature-usage metrics, where losing a few seconds of writes on a node failure is an acceptable trade.

TTL, Expiration, and the Art of Not Lying for Too Long

Time-to-live is the bluntest and most reliable invalidation tool you have. It requires no event plumbing, no message bus, and no coordination between services. It simply promises that any given value will heal itself eventually. The discipline is in choosing the number deliberately for each kind of data rather than copying a default across your whole system.

I think about TTLs in terms of tolerance for staleness, and I find it useful to write that tolerance down per data class:

  • Configuration and reference data such as currency lists or country codes: hours to days, because it barely changes and staleness is harmless.
  • Merchant and customer profile data: minutes, because users notice an update to their own name within a session.
  • Pricing and fee schedules: short and paired with explicit invalidation, because a wrong fee is a customer-trust problem.
  • Authoritative financial state like balances and ledger positions: not cached at all, or cached only behind a hard freshness guarantee.

A second technique worth adopting is jittering your TTLs. If every entry you write expires after exactly 300 seconds, then a burst of traffic that populated the cache together will expire together, and you get a synchronized stampede. Adding a small random offset, say plus or minus ten percent, spreads expirations out and smooths the load on your origin.

Invalidation: The Genuinely Hard Part

There is an old line in our field that captures the whole problem.

There are only two hard things in computer science: cache invalidation and naming things.

It is a joke, but invalidation earns its place on that list. The trouble is that the system that changes the data is often not the system that cached it, so the write path has to know about every cache that might hold a copy and reach out to evict it. As your architecture grows, those dependencies multiply quietly until no one can confidently say which caches a given write affects.

My strongest advice is to prefer expiration over explicit invalidation wherever the staleness budget allows it, and to reserve event-driven invalidation for the few data classes that genuinely cannot tolerate a TTL's worth of lag. When I do invalidate explicitly, I publish a domain event rather than letting the writing service call into caches directly. The service that owns a cache subscribes to the events it cares about and evicts its own keys. This keeps the coupling in the message contract instead of in a tangle of direct calls, and it means a new consumer can be added without touching the writer.

Stampedes, Dogpiles, and Thundering Herds

A cache stampede happens when a popular key expires and many concurrent requests miss at the same instant, all racing to recompute the same expensive value and all hammering the origin at once. The cache was supposed to protect the database, and at the exact moment it expires it does the opposite. This is the failure mode that turns a routine cache miss into an outage.

The standard defense is request coalescing: ensure that for any given key, only one request recomputes the value while the others wait for that result. In a single process this is a lock or a shared task keyed by the cache key. Across a fleet you reach for a distributed lock or a short-lived placeholder value that says "someone is already computing this." A complementary technique is probabilistic early expiration, where a request occasionally chooses to refresh a value slightly before its TTL ends, so the cache is repopulated in the background while the old value is still being served. The combination means a hot key is almost never recomputed by more than one caller at a time.

Distributed Versus Local Caches

A local in-process cache, such as IMemoryCache in .NET, is the fastest cache you can have because there is no network at all. The catch is that every instance has its own copy, so with ten application nodes you have ten caches that can disagree, and invalidating one does nothing for the other nine. A distributed cache like Redis gives you a single shared view that all nodes see consistently, at the cost of a network round trip and a new piece of infrastructure to operate and secure.

In practice I run both as a tiered system. A small, very short-lived local cache absorbs the hottest keys and shields the network, while a distributed cache behind it provides the shared, authoritative cached view. The local layer might hold a value for only a few seconds, just long enough to flatten a burst, while the distributed layer holds it for minutes. This keeps the disagreement window between nodes tiny and bounded, which matters when even your cached data has compliance implications.

Whichever you choose, treat the distributed cache as the operationally serious system it is. It needs encryption in transit, authentication, sensible eviction policies, memory limits, and monitoring, because the moment it holds anything resembling personal or financial data it falls inside the same controls as the rest of your estate.

What I Refuse to Cache

Knowing what not to cache is as important as knowing how to cache. I do not cache authorization decisions in a way that outlives a permission change, because a revoked access that lingers for ten minutes is a security finding, not a performance win. I am extremely cautious about caching anything tied to a regulatory or fraud control, where the whole point is that the latest state governs the decision.

I also resist caching responses that mix data from multiple tenants or users behind a key that does not fully capture who the data belongs to. A cache key that omits the tenant identifier is how you end up serving one customer's data to another, and that is the single most damaging caching bug I have ever had to clean up. When in doubt, the rule is to include every dimension that affects the result in the key, and to default to not caching anything whose freshness is load-bearing for trust or compliance.

Conclusion

Caching rewards engineers who treat it as a deliberate consistency trade rather than a reflexive optimization. Start with cache-aside and an honest TTL, write down the staleness tolerance for each kind of data, and only add the more elaborate machinery, such as write-through, event-driven invalidation, or stampede protection, when a specific data class and a specific failure mode demand it. The goal is not the highest hit rate you can brag about, but a system whose worst-case staleness you can describe in one sentence and defend in front of an auditor. Get that right and caching becomes what it should be: a quiet, well-understood layer that makes everything else faster without ever surprising you.

Chat with us