Skip to content
Mohammed Kharbotli

~/blog/why-adding-redis-doesn-t-automatically-fix-performance

Why Adding Redis Doesn’t Automatically Fix Performance

Redis can improve PHP application performance, but only when it addresses a measured bottleneck. This article looks at inefficient read paths, cache hit costs, stampedes, fallback behavior, and how to decide whether caching will actually improve the system.

Published
Reading time
7 min

When a PHP application slows down, adding Redis is often one of the first suggestions.

Cache the queries. Cache the API responses. Move sessions into Redis.

Those can all be useful changes. But before adding a cache, I want to know what the request is waiting for, how often the expensive work repeats, and how much of it caching would actually remove.

In practice, this is where I start when I inherit a slow application I don’t know yet.

A Symfony application with Doctrine and a plain PHP application with handwritten SQL can have the same underlying problem: too much work per request, too many requests competing for a limited resource, or expensive work happening in the wrong place.

Redis helps with some of those problems. Its presence alone tells me very little about whether the application will be faster.

1. Caching before identifying the expensive part

Suppose an endpoint takes 900 milliseconds:

  • 80 ms fetching database records
  • 120 ms transforming and serializing them
  • 650 ms waiting for an external service
  • 50 ms doing everything else

Caching the database result can remove at most the 80 ms spent fetching it, before accounting for cache access and deserialization.

That might still be worthwhile if database capacity is the constraint. It will have a limited effect on this endpoint’s response time.

Bad: cache whichever operation looks expensive in the code.

$products = $cache->remember(
    'products',
    60,
    fn () => $repository->findAll(),
);

The code contains a database call, so it becomes the suspect. Meanwhile, the external request remains untouched.

A better starting point is to measure the stages separately.

$started = hrtime(true);
 
$products = $repository->findForCatalog($criteria);
 
$metrics->observe(
    'catalog.database_ms',
    (hrtime(true) - $started) / 1_000_000,
);
 
$started = hrtime(true);
 
$availability = $supplier->fetchAvailability($products);
 
$metrics->observe(
    'catalog.supplier_ms',
    (hrtime(true) - $started) / 1_000_000,
);

These snippets use illustrative application wrappers for caching and metrics.

In a real application, I also want request traces and latency distributions. Averages can hide the slow requests that users actually complain about.

I look at queueing too. A request can spend a significant amount of time waiting for a PHP-FPM worker before application-level timing even starts.

2. Using a cache to hide an inefficient read path

One pattern I look for early is a query inside a loop.

$orders = $ordersRepository->findRecent();
 
foreach ($orders as $order) {
    $result[] = [
        'id' => $order->id(),
        'customer' => $customersRepository
            ->get($order->customerId())
            ->name(),
    ];
}

With an ORM, the extra queries may be hidden behind property access. With plain PHP, they are often easier to see but just as expensive.

Good: remove the repeated lookup.

$orders = $ordersRepository->findRecent();
 
$customers = $customersRepository->findIndexedByIds(
    array_unique(array_map(
        fn ($order) => $order->customerId(),
        $orders,
    )),
);

Now the number of database round trips does not grow with the number of orders in the same way.

Sometimes better: fetch exactly what the screen needs.

$stmt = $pdo->prepare(
    'SELECT o.id, o.total_minor, c.name AS customer_name
     FROM orders o
     JOIN customers c ON c.id = o.customer_id
     WHERE o.account_id = :account_id
     ORDER BY o.created_at DESC, o.id DESC
     LIMIT 50'
);
 
$stmt->execute(['account_id' => $accountId]);
 
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

For a read-only listing, a projection can avoid loading full entities and traversing their associations. The query still needs an appropriate index and an execution plan checked against realistic data.

I usually want the uncached path to be reasonable before caching it.

Otherwise, the application is fast only while the cache behaves exactly as expected. An eviction, restart, or expiration exposes the original problem—often when many requests arrive together.

3. Ignoring what a cache hit actually costs

A Redis hit still involves work.

The application builds a key, communicates with Redis, transfers a value, and decodes it. Network distance and payload size matter. So does the number of cache calls per request.

Replacing 100 database queries with 100 sequential Redis requests may help, but it preserves the repeated-round-trip pattern.

Bad: retrieve each value separately.

foreach ($productIds as $id) {
    $cachedProducts[$id] = $redis->get("product:$id");
}

Good: batch independent reads.

$keys = array_map(
    fn (int $id) => "product:$id",
    $productIds,
);
 
$values = $redis->mGet($keys);

The exact batching approach depends on the client and deployment topology. Missing values still need to be handled, ideally with a batched database fetch.

Better, when the access pattern supports it: cache a compact result.

$key = sprintf(
    'catalog:v3:account:%d:locale:%s:page:%d',
    $accountId,
    $locale,
    $page,
);
 
$rows = $cache->remember(
    $key,
    60,
    fn () => $catalog->fetchPage($accountId, $locale, $page),
);

This can reduce round trips and avoid repeated transformation work.

But the key is now part of the application’s correctness. If the result also depends on permissions, currency, filters, or customer-specific pricing, those differences must be represented or caching must happen at a different boundary.

⚠️ I pay particular attention to this in multi-tenant applications. A fast response containing another customer’s data is a serious failure.

Caching entire object graphs also deserves scrutiny. They can carry unnecessary data and make cached values harder to evolve between deployments.

4. Designing for cache hits and forgetting simultaneous misses

The simplest cache-aside code looks harmless:

$value = $cache->get($key);
 
if ($value === null) {
    $value = $reports->build($accountId);
    $cache->set($key, $value, 300);
}
 
return $value;

Assume this wrapper uses null exclusively to mean a miss.

What happens when a popular key expires and 200 requests reach this block together?

Each request can start rebuilding the same report.

This is one of the first places I look when an application is fast most of the time but periodically overwhelms its database.

Adding expiration jitter is a useful improvement:

$ttl = 300 + random_int(0, 60);
 
$cache->set($key, $value, $ttl);

That spreads expiration across different keys. It does not stop concurrent rebuilds of one hot key.

For expensive shared results, I consider a single refresh owner and a bounded stale-data window.

The following is illustrative control flow, using a lock wrapper that releases only a lock still owned by its caller:

$entry = $cache->getEntry($key);
 
if ($entry !== null && $entry->isFresh()) {
    return $entry->value();
}
 
$lock = $locks->tryAcquire("refresh:$key", leaseSeconds: 10);
 
if ($lock === null) {
    if ($entry !== null && $entry->isAcceptablyStale()) {
        return $entry->value();
    }
 
    throw new ReportTemporarilyUnavailable();
}
 
try {
    // Another request may have refreshed before we got the lock.
    $entry = $cache->getEntry($key);
 
    if ($entry !== null && $entry->isFresh()) {
        return $entry->value();
    }
 
    $value = $reports->build($accountId);
 
    $cache->storeEntry(
        $key,
        $value,
        freshForSeconds: 300,
        staleForSeconds: 60,
    );
 
    return $value;
} finally {
    $lock->release();
}

The cached entry must remain physically available through the stale window.

The lock lease also needs care. If rebuilding exceeds it, another refresh can begin. Depending on the consequences, that may require renewal, protection against an older result overwriting a newer one, or accepting duplicate work.

I would use a tested implementation where possible. For a report that routinely takes a long time to generate, I would also question whether rebuilding belongs inside a web request at all.

Serving stale data is a product decision. It may be acceptable for a dashboard and unacceptable for a decision that authorizes spending.

5. Assuming the database can absorb every cache failure

A common fallback is:

try {
    return $cache->remember($key, 60, $loadFromDatabase);
} catch (CacheUnavailable $e) {
    return $loadFromDatabase();
}

That may be reasonable at low volume.

But suppose the application normally serves 95% of these reads from cache. If every request falls through during an outage, database reads for this workload can rise to roughly 20 times their normal level.

“Fall back to the database” is a capacity decision.

I want to know whether that fallback has been tested, whether cache timeouts are short enough for the request budget, and whether repeated connection attempts will tie up PHP workers.

For expensive optional work, a bounded fallback can be more useful:

try {
    return $cache->remember($key, 60, $loadFromDatabase);
} catch (CacheUnavailable $e) {
    $permit = $fallbackBudget->tryAcquire();
 
    if ($permit === null) {
        throw new ReportTemporarilyUnavailable();
    }
 
    try {
        return $loadFromDatabase();
    } finally {
        $permit->release();
    }
}

The fallback budget must work independently of the Redis instance that just failed. Its scope matters too: a limit per worker is very different from a limit across the deployment.

For some features, I would serve a previously generated snapshot. For others, I would return a controlled temporary failure or shed optional work.

I would rather make that decision explicitly than discover during an incident that every application instance is sending unrestricted fallback traffic to the same database.

What I Want to See Before Calling It Faster

After adding caching, I compare response-time distributions, database load, cache payload sizes, and PHP worker saturation.

I also check cold-cache behavior and what happens when Redis becomes slow or unavailable. A high hit rate alone is not enough: the remaining misses may still dominate latency or overload the database.

Redis is useful when it removes measured, repeatable work at a cost the application can afford.

Before I recommend it, I want to be able to finish this sentence:

“We are caching this result because…”

The rest of that sentence should describe the workload, the freshness requirement, and the expected benefit.