Caching is a data consistency contract, not a speed hack. Master cache invalidation, TTL strategy, stale reads, and stopping cache stampedes on real apps.
A client once messaged me at 11pm: their app "just started showing wrong prices sometimes." Not always, not for everyone, not on every screen. Just sometimes, for some users, somewhere. It took us two days to find, and the culprit was a single line someone had added six months earlier because a list screen felt sluggish. They cached the product catalog for an hour. Prices changed more often than that. The cache did exactly what it was told: it kept serving the answer it had, long after that answer stopped being true.
Nobody warns you about this when you add your first cache. You think you're buying speed. You're actually signing a contract. You now have two places that each claim to know the truth, and you've quietly promised every reader that the two will agree. A cache is not a performance trick you sprinkle on at the end. It is a second source of truth, and the moment you own two of those, you owe someone consistency. Take that promise seriously and it does something surprising: it tells you exactly what to cache, for how long, and when to rip the cache back out.
This post is about treating caching as a data-consistency problem, not a speed hack. I'll walk through the caching patterns I actually reach for on production Flutter and Firebase apps, how I set TTLs, how cache invalidation quietly goes wrong, and the failure modes — stale reads, thundering herd, cache stampedes — that show up long before you think you're "at scale."
This is the reframe I wish someone had handed me at 25. The instant you copy a value somewhere faster to read, you have built a tiny distributed system. Two nodes now hold the same fact. They can disagree. Network and timing decide who is right and for how long. Everything hard about distributed systems now lives inside your "simple little cache."
That sounds dramatic for a Redis GET. It isn't. The classic hard problems of distributed data all show up at cache scale:
None of this is exotic. It's the daily texture of any app with a cache layer, whether that layer is Redis, an in-memory Map, an HTTP Cache-Control header, or Firestore's offline persistence. The reason it bites is that we frame caching as an optimization, so we reason about it like an optimization — "did it get faster?" — instead of like a data system — "is it still correct?" Speed is easy to measure and easy to celebrate. Correctness fails silently, sometimes, for some users, on some screens.
So the first move is a mental one. Stop asking "will this be faster?" Start asking "if this copy is wrong for the next 60 seconds, who gets hurt, and how badly?" That single question does most of the design work for you, and it's the question that separates a caching strategy from a caching accident.
There are only a few honest caching patterns, and picking one is really about deciding who owns the consistency promise between your cache and your database.
Cache-aside (lazy loading). Your code checks the cache; on a miss it reads the source, then populates the cache. Simple, and the default almost everywhere — it's how most read-through caching in the wild actually works.
Future<Product> getProduct(String id) async { final cached = await cache.get('product:$id'); if (cached != null) return Product.fromJson(cached); final product = await db.fetchProduct(id); await cache.set('product:$id', product.toJson(), ttl: Duration(minutes: 5)); return product;}The trap is the write path. Cache-aside says nothing about what happens when the product changes. Most bugs I've chased live in that silence — someone updates the row and assumes the cache "will refresh eventually." It will. Just not before your user sees the old number.
Write-through. Writes go through the cache, which updates the source synchronously. The cache is never behind the source, so reads are always consistent — but every write pays the cache's cost, and you've now made the cache part of your write path's reliability story. If the cache is down, writes stall.
Write-behind (write-back). Writes hit the cache and get flushed to the source later, asynchronously. Fast writes, real risk: a crash between the two loses data. I reach for this rarely and only for data I can afford to lose — analytics counters, view tallies, the kind of thing where an approximate number beats a slow one.
Which do I default to? Cache-aside, almost always, because it fails safe: a cold or broken cache just means a slower read, never a lost write. I only graduate to write-through when a stale read is genuinely unacceptable and the data changes rarely enough that paying on every write is cheap.
Then there's invalidation, which Phil Karlton famously ranked among the two hard problems in computer science. The reason it's hard isn't the delete call — cache.del('product:$id') is trivial. It's that you have to remember every place a fact lives.
That product isn't just at product:$id. It's baked into the cached category list, the search results, the "related products" block, the homepage feature rail, maybe a denormalized copy in someone's user feed. Change the price once and you have five stale copies, four of which you forgot existed. This is the fan-out problem, and it's why invalidation bugs feel like whack-a-mole: every new feature that reads the product silently adds another place you now have to remember to invalidate.
My rule after enough of these: prefer expiry over invalidation whenever the data can tolerate it. A short TTL is a dumb, reliable janitor that cleans up even the copies you forgot about. Explicit invalidation is a promise you have to keep at every single write site, forever, including the ones your teammate adds next quarter without reading this code. When correctness genuinely can't wait for a TTL — a permission change, say — invalidate. But treat every manual invalidation as a liability you're choosing to carry, not a feature you're proud of.
Most TTLs I see in the wild are round numbers someone typed once. Five minutes. One hour. A day. They feel arbitrary because they are.
A TTL is not a performance knob. It's you stating, out loud, how stale you're willing to lie to a user. That's the honest way to read ttl: 3600: "I am comfortable serving an answer that could be up to an hour out of date." Say it in those words and suddenly the number stops being arbitrary. You already know how wrong you can afford to be for exchange rates versus a user's avatar. The TTL is just that tolerance, encoded.
So derive it from the data, not from habit:
One piece people skip: TTL and refresh strategy are separate decisions. A hard TTL means the entry vanishes and the next reader eats a full cache miss — a latency cliff right when the data expires. Often better is stale-while-revalidate: serve the stale value instantly, kick off a background refresh, and let the next reader get the fresh one.
Future<Product> getProduct(String id) async { final entry = await cache.getEntry('product:$id'); // value + age if (entry != null) { if (entry.isStale) { _refreshInBackground(id); // fire-and-forget; don't await } return Product.fromJson(entry.value); // return instantly, even if stale } return _fetchAndCache(id);}The user never waits, and the window of staleness stays short and bounded. On a dashboard we ship, this pattern took a heavy aggregate query off the critical path entirely — reads stayed instant, and the data was never more than a few seconds behind. If you use HTTP caching, Cache-Control: max-age=… stale-while-revalidate=… gives you the same behavior at the CDN layer for free.
Not all stale data costs the same, and this is where most caching decisions actually get made — or should. The same 60 seconds of staleness ranges from invisible to career-limiting depending on where in the product it lands.
| Data | Cost of being 60s stale | Reasonable posture |
|---|---|---|
| Marketing copy, blog post body | None | Cache hard, long TTL, CDN |
| Product listing / catalog | Mild — slightly old, rarely harmful | Cache with short TTL |
| Inventory "in stock" count | Real — oversell risk | Short TTL, or read source on checkout |
| Account balance / wallet | High — wrong number, real money | Don't cache the number; cache around it |
| Auth / permissions | Severe — security boundary | Cache carefully, invalidate aggressively |
| Payment authorization | Do not cache | Always hit source of truth |
The pattern: staleness cost scales with how close the data sits to money, identity, and safety. Copy and catalog can drift for a minute and nobody notices. A permission that's stale for a minute means a user you just offboarded can still read a document for another minute. That's not a performance conversation anymore — it's a security incident with a stopwatch on it.
The practical move is to cache the expensive part, not the sensitive part. On a wallet screen you don't cache the balance — you cache the rendered transaction history, the exchange rates, the merchant logos, all the slow stuff around it, and you read the one number that matters live. You get most of the speed and keep the promise where it counts. I apply the same split to auth: cache the user's profile and preferences freely, but re-check permissions against the source on anything that gates access.
People assume stampedes are a big-tech problem. They are not. You need surprisingly little traffic to feel one.
The setup: a popular key expires. At that exact instant, every in-flight request misses, and they all run the expensive query at once, hammering the very database the cache was protecting. The cache expiring makes your database load spike instead of drop — a thundering herd stampeding through the one door your cache was holding shut. I've watched a modest app fall over from this on a launch day, with traffic that any single Postgres box should have shrugged off.
Three defenses, roughly in order of how often I reach for them:
1. Single-flight / request coalescing. When a key is missing, let exactly one caller recompute it and make everyone else wait for that one result.
final _inflight = <String, Future<Product>>{};Future<Product> getProduct(String id) async { final cached = await cache.get('product:$id'); if (cached != null) return Product.fromJson(cached); // Everyone asking for the same key rides one recomputation. return _inflight.putIfAbsent(id, () async { try { final product = await db.fetchProduct(id); await cache.set('product:$id', product.toJson(), ttl: Duration(minutes: 5)); return product; } finally { _inflight.remove(id); } });}2. Jittered TTLs. If you seed a thousand entries in one batch with the same TTL, they expire in one synchronized wave. Add randomness — ttl + random(0, 60s) — so expiries spread out over time instead of detonating together. This one line prevents the self-inflicted stampede that batch cache warming otherwise guarantees.
3. Early / probabilistic refresh. Refresh a key slightly before it expires, so a fresh value is ready before anyone hits an empty slot. This is stale-while-revalidate wearing a helmet, and probabilistic early expiration (refresh with rising likelihood as the entry ages) spreads even that refresh out across readers.
For most apps, single-flight plus a little TTL jitter removes the entire class of problem. You don't need anything fancier until you're much bigger than you think.
Now the counterintuitive part. When people decide to "add caching," they instinctively reach for the hot loop — the function that runs ten thousand times. But that function is usually hot because it's already cheap. Caching a 0.2ms call to save 0.2ms is a rounding error dressed up as an optimization, and now you own an invalidation bug for no reward.
The real wins hide in the rare-but-brutal operations:
That last one is the biggest lever most small apps ignore: caching per-request work that's identical across users. If a thousand logged-out visitors all get the byte-for-byte same homepage, computing it once and serving it a thousand times isn't just faster — it flattens your entire load profile and your bill.
On one Firebase project we cut a screen from roughly 40 reads down to 3, and the win wasn't the raw latency — it was that Firestore read costs and cold-start pain both dropped, because we stopped paying for the same computation over and over. The lesson stuck: profile for total cost, not per-call latency. Multiply how expensive an operation is by how often it runs, sort that list descending, and cache from the top. The hot-but-cheap loop is almost never at the top of that list.
Before you add a cache, run the candidate through five questions. If it doesn't clear them, don't cache it — you'd be taking on a consistency debt for a speed win you can't bank.
A rough scoring: slow, read-heavy, stale-tolerant, and easy to expire is a clear yes. Cheap or write-heavy is a clear no. Slow-but-sensitive is the interesting middle — cache the expensive scaffolding, read the sensitive core live.
The uncomfortable last question is the most useful one: when do you give up on the cache? You give up when keeping the promise costs more than the speed is worth. When every write site sprouts three invalidation calls, when the staleness bugs outnumber the latency complaints, when you're caching a thing that changes as often as it's read — that's the signal to delete the cache and take the honest, slightly slower, always-correct read.
I have removed more caches than I regret adding. Deleting one is not an admission of failure. It's paying off a debt you decided you no longer wanted to carry.
A cache is a second source of truth, and truth you copy is truth you now owe someone consistency on.