Blog
The Cache Is Lying to You
- software-engineering
- caching
- laravel
- typescript
- performance
A page is slow. Someone wraps the query in a cache call. The page is fast. Everyone moves on.
Two weeks later a customer updates their address and the invoice still shows the old one. Support asks if they tried refreshing. They did. Many times.
Caching is the easiest performance win there is. It's also the easiest way to ship data that's confidently wrong.
Every cache is a promise about staleness
When you cache something, you're saying "it's fine if this is out of date for a while." That's the actual decision. The TTL is just the number you wrote down.
So before adding one, answer a boring question: how stale is okay?
- A list of countries? Days. Weeks. Forever, honestly.
- A product price? Probably minutes, and only if checkout reads the real value.
- A user's permissions? Close to zero. Nobody wants "revoked access" to mean "revoked in an hour."
If the answer is "it can never be stale," don't cache it. Make the query faster instead.
The one liner that hides the decision
Laravel makes this almost too easy:
$stats = Cache::remember('dashboard.stats', 600, function () {
return [
'orders' => Order::count(),
'revenue' => Order::sum('total_cents'),
];
});Ten minutes, fine. That's a dashboard. Nobody's making payroll decisions off it.
The problem is when the same pattern gets copied onto something that matters, with the same 600, because that's what the last one had.
Somebody has to clear it
A TTL is the lazy way to invalidate. The more accurate way is clearing the key when the data changes:
class Customer extends Model
{
protected static function booted(): void
{
static::saved(function (Customer $customer) {
Cache::forget("customer.{$customer->id}.summary");
});
}
}This works until someone updates customers with a query builder update(), which skips model events entirely. Or a raw SQL script. Or a different app sharing the database.
Event based invalidation is great. Just know every path that writes the data, or keep a TTL as a backstop for the ones you missed.
Keys are part of the data
Cache keys look like an implementation detail. They aren't.
Cache::remember("report.{$teamId}.{$month}", 3600, fn () => $this->build($teamId, $month));Forget $teamId in that key and every team sees whichever team loaded the page first. That isn't a performance bug. That's a data leak with a nice response time.
Anything that changes the result belongs in the key. Team, user, locale, filters, page number. If the output depends on it, the key does too.
When it expires, everyone shows up at once
A popular key expires. Fifty requests miss at the same moment. All fifty run the expensive query. The database has a bad minute.
One option is a lock, so only one request rebuilds:
$value = Cache::lock('dashboard.stats.rebuild', 10)->block(5, function () {
return Cache::remember('dashboard.stats', 600, fn () => $this->buildStats());
});The first request rebuilds. The others wait, then read what it stored. Locks need a cache driver that supports them, like Redis, Memcached, or the database driver.
Recent Laravel versions also have Cache::flexible(), which serves the stale value for a window while refreshing it in the background after the response is sent. Check your version before reaching for it.
The frontend caches too
The browser and your fetch layer are caches with opinions. A tiny in memory one in TypeScript looks harmless:
const cache = new Map<string, { value: unknown; expiresAt: number }>();
export async function cachedJson<T>(url: string, ttlMs: number): Promise<T> {
const hit = cache.get(url);
if (hit && hit.expiresAt > Date.now()) {
return hit.value as T;
}
const res = await fetch(url);
if (!res.ok) {
throw new Error(`${url} failed: ${res.status}`);
}
const value = await res.json();
cache.set(url, { value, expiresAt: Date.now() + ttlMs });
return value as T;
}Notice it only caches successful responses. Cache a 500 and you've turned one bad second into a bad five minutes.
Same staleness question applies. If the user just saved something and the next screen reads from this, they'll see their old data and assume the save failed. Clear the entry after a write:
cache.delete(`/api/customers/${id}`);Cache the slow thing, not everything
Before caching, find out why it's slow. A missing index or an N+1 loop is a fix. Caching it just makes the problem show up less often, usually right after the cache gets flushed during a deploy.
A cache should make a correct, reasonably fast thing faster. It shouldn't be the only reason the page loads at all.
The habit
Every time you add a cache, write down three things:
- How stale is this allowed to be?
- What clears it when the data changes?
- What happens when it's empty?
If you can't answer those, you don't have a cache. You have a bug with a timer on it.