Redis Caching in Financial Systems
When a Stale Balance Is a Compliance Problem
When a Stale Balance Is a Compliance Problem
Every caching tutorial treats staleness as a performance trade-off — how long can this data be wrong before someone notices, and does it matter. For most data, that's the right question. For an account balance, it's the wrong one, because the person who notices first is a customer deciding whether they can afford something, and "the cache was 40 seconds behind" is not a defense a regulator finds persuasive. This one's for the backend engineer choosing a caching strategy for balance-adjacent data, and the delivery lead who needs to understand why the "obvious" choice isn't always the safe one.
A stale product recommendation is a missed opportunity. A stale account balance is a customer making a real financial decision on wrong information, and it sits close enough to disclosure and payment-services obligations — showing customers accurate, timely account information — that a systemic caching bug isn't a performance postmortem, it's the kind of control gap we've written about in the context of reconciliation and DORA operational resilience: not dramatic on its own, but exactly the sort of thing an auditor eventually asks "how did this happen, and for how long."
That framing matters because it changes the actual engineering question. The question isn't "what's the fastest way to cache a balance." It's "what's the caching strategy whose failure mode is acceptable" — because every caching strategy fails eventually, and the two patterns below fail in different ways.
Cache-aside is the pattern most Spring/Redis integrations reach for by default: check the cache, on a miss load from the database and populate the cache, and on a write, update the database and delete the cache key rather than update it.
1 @Service
2 public class BalanceService {
3
4 public BigDecimal getBalance(UUID accountId) {
5 String cached = redis.opsForValue().get("balance:" + accountId);
6 if (cached != null) return new BigDecimal(cached);
7
8 BigDecimal balance = accountRepository.getBalance(accountId);
9 redis.opsForValue().set("balance:" + accountId, balance.toString(), Duration.ofSeconds(30));
10 return balance;
11 }
12
13 @Transactional
14 public void applyTransaction(UUID accountId, BigDecimal delta) {
15 accountRepository.adjustBalance(accountId, delta); // DB commit first
16 redis.delete("balance:" + accountId); // then invalidate
17 }
18 }
The ordering — commit the database write, then delete the cache key, never the reverse — is the part worth treating as non-negotiable rather than a style preference. Deleting first opens a window where a concurrent read can miss, reload the still-old value from the database, and write it back into the cache after your delete completes — leaving a stale balance cached with nothing left to correct it until the TTL expires.
Even with the correct ordering, cache-aside has one race left: a slow reader that started before the write, misses, and populates the cache with the old value after the delete already ran. This is bounded — the TTL puts a hard ceiling on how long the staleness can persist — but for a balance, "bounded by 30 seconds" is still 30 seconds a customer could be looking at the wrong number. For genuinely hot keys, the fix is the delayed double-delete: schedule a second delete a few hundred milliseconds after the first, on the assumption that any concurrent read racing the write will have finished by then. It adds a small amount of complexity for a real reduction in the exposure window.
Write-through avoids the race entirely by writing the new value to the cache and the database as part of the same operation, so a read never sees a gap between "old value still cached" and "new value not yet cached" — there's no window for a stale re-population to land in.
1 @Transactional
2 public void applyTransactionWriteThrough(UUID accountId, BigDecimal delta) {
3 BigDecimal newBalance = accountRepository.adjustBalance(accountId, delta);
4 try {
5 redis.opsForValue().set("balance:" + accountId, newBalance.toString(), Duration.ofSeconds(30));
6 } catch (RedisConnectionFailureException e) {
7 redis.delete("balance:" + accountId); // fail safe: force a DB read next time
8 log.warn("Cache write failed for account {}; forcing fallthrough", accountId, e);
9 }
10 }
The failure mode that matters here is the one most write-through examples skip: the database commit succeeds and the cache write fails — a network blip, a Redis node mid-failover. Unlike cache-aside, there's no natural self-healing here; nothing is going to notice the mismatch and correct it until the TTL expires on its own. The catch block above is the part that actually matters: on a cache-write failure, delete the key rather than leaving whatever was there — silently serving the previous balance until TTL is a materially worse failure than a temporary cache miss that falls through to the database. Treat a write-through cache-write failure as "we don't know what's in here anymore," not as a warning to log and move past.
Neither pattern is universally correct, and picking one without naming the failure mode you're accepting is how a team ends up defending an incident after the fact instead of designing around it beforehand.
Cache-aside with a short TTL and delete-on-write is the right default for balance-adjacent data specifically because its worst case is well-understood and bounded: a brief, TTL-limited staleness window, self-correcting even if the corrective code has a bug, because the TTL always wins eventually. It's also simpler to reason about under partial failure — a Redis outage degrades gracefully into "every read hits the database," which is slower, not wrong.
Write-through earns its place where read latency on a cold cache is itself unacceptable — a high-frequency trading or fraud-scoring path where even a single database round-trip on a miss is too slow — and where the team is willing to build the explicit fail-safe invalidation path above, rather than trusting the happy path. Without that fail-safe, write-through's worst case is unbounded and silent, which is the opposite of what you want from a control that a regulator might eventually ask about.
For most balance-display use cases, the honest answer is that cache-aside's bounded, self-correcting failure mode is the easier one to stand behind in an incident review, and the latency it costs — one extra database round-trip on a cache miss — is a price worth paying for not having to explain why a cache silently disagreed with the ledger for an hour. Reach for write-through only when the latency requirement genuinely forces it, and never without the explicit cache-write-failure handling that keeps its failure mode bounded too.