Skip to content

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.

5 min read

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.

Related questions

Is Redis not fast enough to make this moot?
A Redis lookup is a network round trip: fast, but not free, and the cost is per call rather than per page. Replace one 8ms query with forty 0.4ms cache reads and you have made the page slower while every graph you look at says the cache is healthy.
How do I know whether a cache is earning its place?
Measure the page with it and without it, on production-shaped data, and look at the hit rate. A cache below roughly eighty per cent hits is usually paying the miss cost often enough to cancel the wins, and one below fifty is costing you outright.
What is a cache stampede?
Many requests finding the same key expired at the same moment and all recomputing it at once. The expensive query you cached to avoid running frequently now runs a hundred times in one second, under the load that made it expensive.
Are cache tags worth using?
They are the cleanest way to invalidate a group of keys and they need a store that supports them - Redis or Memcached, not file or database. The cost is that flushing a tag scans, so a tag covering a very large number of keys makes invalidation the slow operation instead of the read.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page