When Caching Makes a Laravel Application Slower
Caching is the first thing reached for and the last thing measured. Four patterns that add latency instead of removing it, and the question worth asking before any of them.
Every slow application eventually gets a cache. It is the intervention with the best story: the work is expensive, so do it once and keep the answer. Often it works, and the page that took two seconds now takes two hundred milliseconds.
Often enough to be worth writing about, it does not. Here are the shapes it takes when it goes the other way.
One cache call per row
The most common version, and the hardest to see because every individual piece of it is correct.
foreach ($orders as $order) {
$rate = Cache::remember("fx.{$order->currency}", 3600, fn () =>
$this->rates->for($order->currency)
);
}Each call is a millisecond. There are four hundred orders, so the page spends four hundred milliseconds asking a fast system the same handful of questions repeatedly. The original query it replaced ran once and took twelve.
The cache did not fail here. It was put inside a loop, which turned one expensive operation into hundreds of cheap ones and then added them up. Cache the collection rather than the item, or read the distinct set once before the loop starts.
Caching something cheaper than the lookup
An indexed primary-key select on a warm table is well under a millisecond. A Redis round trip over the network is comparable, sometimes slower, and now you own two systems where one would do - plus the invalidation.
This is worth being blunt about because it is where the most effort gets spent for the least return. Before caching anything, time the thing you are about to cache. If the answer is a single-digit number of milliseconds, the cache is almost certainly not the improvement you are looking for.
The stampede
A key holding a report that takes four seconds to build, expiring at midnight, on a site with traffic at midnight. Every request that arrives in the next four seconds finds the key empty and starts building it. Nothing is serving from cache, the database is running the same heavy query in parallel a hundred times, and the outage is caused by the cache rather than prevented by it.
Laravel ships the answer:
$report = Cache::lock('report:monthly', 10)->block(5, function () {
return Cache::remember('report:monthly', 3600, fn () => $this->build());
});One request rebuilds; the others wait and then read what it wrote. The alternative - and the better one where staleness is acceptable - is to never let the key expire under traffic at all: refresh it from a scheduled command and let readers always find something there.
Invalidation that costs more than the reads
A cache is worth what it saves minus what it costs to keep correct. Tie a key to a model's updates and a bulk import touching fifty thousand rows fires fifty thousand invalidations, each one a write to the cache store. The page reads were saving eight milliseconds apiece. The import is now taking four minutes longer.
The same trap comes with tags that are too broad. A tag covering every key for a tenant is convenient until flushing it has to scan a large keyspace, at which point invalidation is the slow path and it runs on every write.
The question to ask first
Not what should we cache but why is this slow.
A cache in front of an N+1 makes the N+1 cheaper to run, and it will still be there, and it will still be running - now with a second system involved and a correctness problem attached. A cache in front of a missing index is a way of not adding the index. Neither of those is illegitimate as a stopgap, but both should be written down as one, because a stopgap nobody wrote down is architecture by the following year.
Where the answer genuinely is that the work is expensive and correct and has to happen anyway - a third-party call with a rate limit, an aggregate over millions of rows, a rendered document - caching is exactly right. That is a narrower set of cases than the reflex suggests.
What to measure
Three numbers, all of which are cheap to get and rarely looked at:
Hit rate per key prefix. Below eighty per cent and the miss cost is eating the wins. Below fifty and you are paying for a cache you effectively do not have.
Cache calls per request. One is a cache. Forty is a loop you have not noticed yet.
The page with the cache disabled. If the difference is small, the cache is complexity with no return - and deleting it is a performance improvement in the only sense that matters, which is the number of things that can be wrong.
Often the query underneath was the problem all along, and N+1 is the usual shape it takes. If it is the whole request path and not one query, performance work begins with a measurement. The cache comes later, or it does not come at all.
