Implementing the Cache-Aside Pattern Correctly
AI generated
SET
TTL
Redis · Caching · Backend Architecture
Implementing the Cache-Aside Pattern Correctly
Lazy loading without silent failures

The cache-aside pattern moves all caching logic into the application: it checks Redis, loads from the database on a miss, and fills the cache itself. Anyone who ignores error handling, TTL values and race conditions ends up with silent inconsistencies and unnecessary database load, even though Redis is supposed to prevent exactly that.

18 min read Lazy Loading · Cache Miss · Stale Data · PHP Redis 7.x · Predis · phpredis

1. What the cache-aside pattern is and when it fits

In the cache-aside pattern, full responsibility for reading, filling and invalidating the cache sits in the application, not in Redis itself. Redis only knows simple key-value operations in this model: GET, SET, DEL. The application decides on every access whether it consults the cache first, and it also decides what happens on a cache miss. This separation makes cache-aside the most widely used caching pattern of all, because it requires no special Redis configuration and works in practically any application architecture.

The cache-aside pattern is particularly well suited to read heavy workloads with uneven access distribution, such as product detail pages, user profiles or configuration data. It fits less well when nearly every record is read with equal frequency, so the cache barely reduces load, or when writes are so frequent that the invalidation logic becomes the bottleneck. In those cases it is worth looking at write-through or write-back strategies as an alternative to plain cache-aside.

2. Lazy loading: the core mechanic in the application layer

The core of the cache-aside pattern is lazy loading: data only ends up in the cache once it has actually been requested, not ahead of time. The flow is always the same three-step cycle. First, the application checks with GET whether the key exists in Redis. Second, if it does, the value is returned directly without touching the database. Third, if it does not, the application reads from the database, writes the result into Redis with SET and a TTL, and returns it. This cycle is the actual definition of the cache-aside pattern.

The advantage of lazy loading over eager pre-filling is that memory is only consumed by data that is actually requested. A Redis server with limited memory fills up with the genuinely relevant, frequently read records, while rarely requested records never burden the cache. The downside shows up on the first access, which is always slower because it goes through the full database roundtrip before the cache takes over, an effect commonly known as cold start.


# Trace lazy loading manually: key does not exist yet
redis-cli GET product:4711
# (nil)

# Application reads from MySQL, fills Redis with a TTL
redis-cli SET product:4711 '{"id":4711,"name":"Coffee Maker","price":89.90}' EX 3600

# Second access: cache hit, no database access needed anymore
redis-cli GET product:4711
# {"id":4711,"name":"Coffee Maker","price":89.90}

# Check TTL to see remaining lifetime
redis-cli TTL product:4711
# (integer) 3587

3. Implementing read-through logic in PHP

In the application layer, the cache-aside pattern is usually encapsulated in its own method that can be called from anywhere in the code, without callers needing to know whether the data came from Redis or from the database. This encapsulation matters because it prevents caching logic from being scattered across the whole codebase. With Predis, this read-through logic can be implemented cleanly in a few lines, including JSON serialization of the data structure.

An important detail during implementation is that the method must return a clear value and must not silently return null when both the cache and the database are empty. Only then can a real cache miss later be distinguished from a record that genuinely does not exist. The following snippet shows a typical cache-aside implementation for a product repository.


<?php

declare(strict_types=1);

final class ProductCacheRepository
{
    public function __construct(
        private readonly \Predis\Client $redis,
        private readonly ProductDatabaseRepository $database,
        private readonly int $ttlSeconds = 3600
    ) {
    }

    /**
     * Cache-aside read: check Redis first, fall back to database on miss.
     */
    public function find(int $productId): ?array
    {
        $key = "product:{$productId}";
        $cached = $this->redis->get($key);

        if ($cached !== null) {
            return json_decode($cached, true);
        }

        // Cache miss: load from source of truth
        $product = $this->database->find($productId);

        if ($product === null) {
            return null; // do not cache "not found" without a strategy
        }

        $this->redis->setex($key, $this->ttlSeconds, json_encode($product));

        return $product;
    }
}

4. Handling cache misses correctly

A cache miss in the cache-aside pattern is not an error case, it is a normal, expected branch of the flow that still needs to be handled cleanly. The biggest risk is that many concurrent requests for the same key on a miss all hit the database in parallel, instead of one request filling the cache while the rest benefit from it. This problem is called cache stampede and can be mitigated with locking mechanisms, for example using SET key value NX EX ttl as a distributed lock.

A second important point: records that do not exist should also be marked in the cache, but with a short TTL and a clear sentinel value such as an empty JSON object. Without this safeguard, every request for a non existing ID repeatedly triggers a full database roundtrip, a pattern known as cache penetration that can be deliberately exploited by malicious clients to overload the database.

5. Stale data: TTL strategies against outdated values

Because the cache-aside pattern copies data instead of referencing it, there is inevitably a window in which the Redis value diverges from the database value, known as stale data. The TTL is the primary tool for bounding this window: a short TTL reduces the maximum duration of inconsistency but increases the number of cache misses and thus database load. A long TTL relieves the database but extends the risk of stale responses. The right TTL is always a trade-off that depends on how frequently the specific data changes.

For data with different volatility, a differentiated TTL strategy is worthwhile instead of a single global constant: product prices, which can change several times a day, need a shorter TTL than static product descriptions. In addition, jitter, a small random deviation from the base TTL, helps prevent many keys from expiring at the same moment and thereby triggering a wave of simultaneous cache misses.

6. Invalidation on write operations

Besides the TTL, active invalidation is the second tool against stale data in the cache-aside pattern. On every write operation that changes a cached record, the application must explicitly remove the matching Redis key with DEL, after the database transaction has committed. The order matters: if the cache is cleared first and the database write fails afterward, an inconsistent state arises in which the next reader loads the old data back into the cache.

For complex data models with several dependent cache entries, such as a product list and individual product details, invalidation must cover all affected keys. A proven cache-aside pattern for this is using namespaces with a version number as part of the key, so that a single increment of the version implicitly invalidates all dependent keys, without every single key needing to be known.


# Direct invalidation after a successful database commit
redis-cli DEL product:4711

# Versioned namespace: increment the version once instead of
# deleting individual keys
redis-cli INCR product:version
# (integer) 8

# All subsequent cache-aside keys use the current version
redis-cli SET "product:v8:4711" '{"id":4711,"price":94.90}' EX 3600

7. Error handling when Redis is unreachable

A robust cache-aside pattern treats Redis as an optional accelerator, never as a hard dependency. If the Redis connection fails, the application must transparently fall back to the database instead of returning an error to the user. In PHP this means wrapping every Redis call in try-catch blocks and explicitly catching connection errors, without letting the exception propagate up to the controller.

A short connection timeout is equally important, so that an unreachable Redis server does not dominate the application's response time with long waits. A timeout of 100 to 200 milliseconds is a sensible starting point for most web applications, well below the delay a user perceives as noticeable.


<?php

declare(strict_types=1);

final class ResilientCacheAsideRepository
{
    public function __construct(
        private readonly \Predis\Client $redis,
        private readonly ProductDatabaseRepository $database
    ) {
    }

    /**
     * Cache-aside read with graceful degradation on Redis failure.
     */
    public function find(int $productId): ?array
    {
        $key = "product:{$productId}";

        try {
            $cached = $this->redis->get($key);
            if ($cached !== null) {
                return json_decode($cached, true);
            }
        } catch (\Predis\Connection\ConnectionException $e) {
            // Redis unreachable: log and fall through to database
            error_log("Redis unavailable, falling back: " . $e->getMessage());
        }

        $product = $this->database->find($productId);

        if ($product !== null) {
            try {
                $this->redis->setex($key, 3600, json_encode($product));
            } catch (\Predis\Connection\ConnectionException $e) {
                // Cache write failure is non-fatal for a read path
                error_log("Redis write failed: " . $e->getMessage());
            }
        }

        return $product;
    }
}

8. Observing hit rate and latency

A cache-aside pattern without monitoring is a black box. The central metric is the hit rate, the ratio of cache hits to all requests, which Redis provides directly through INFO stats. A hit rate noticeably below 80 percent for a frequently read record usually points to a TTL that is too short, a key structure that is too coarse, or excessive invalidation.

Besides the hit rate, it is worth observing the response time distribution, split by cache hit and cache miss. A typical pattern: cache hits sit in the low single digit millisecond range, while cache misses are 10 to 50 times slower due to the database access. This difference shows concretely how much latency the cache-aside pattern actually saves and justifies the additional implementation effort.


# Calculate hit rate from Redis statistics
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
# keyspace_hits:184920
# keyspace_misses:21044

# Hit rate = hits / (hits + misses)
# 184920 / (184920 + 21044) = 89.8 %

# Identify slow commands (latency outliers)
redis-cli --latency-history -i 5

# Check current memory usage per keyspace
redis-cli INFO keyspace

9. Cache-aside compared to other patterns

The cache-aside pattern is not the only way to put Redis in front of a database. The choice between patterns depends on the ratio between read and write load and on the tolerance for stale data.

Pattern Who fills the cache Consistency Typical use
Cache-Aside Application, on cache miss Can go stale until next TTL Read heavy workloads, product data
Read-Through Cache layer itself, transparently Can go stale until next TTL Frameworks with cache abstraction
Write-Through Application, on every write Always current Consistency critical data
Write-Back Application, asynchronously delayed Briefly inconsistent Write heavy counters, metrics

In practice, the cache-aside pattern is usually combined with a moderate TTL, while consistency critical fields are kept current with write-through in a targeted way. This combination uses the simplicity of the cache-aside pattern for the bulk of the data and reserves the higher implementation effort of other patterns for the few fields where stale data is actually a problem.

Mironsoft

Redis architecture, caching strategies and backend performance

Want the cache-aside pattern built cleanly into your architecture?

We analyze your access patterns, design suitable TTL strategies and implement robust cache-aside logic with clean error handling and a database fallback.

Architecture Review

Analysis of existing caching logic for stale data and cache stampede risks

Implementation

Building cache-aside repositories with error handling and monitoring

Monitoring

Keeping hit rate, latency and memory usage in view permanently

10. Summary

The cache-aside pattern deliberately shifts control over caching into the application: lazy loading fills Redis only with data that is actually requested, TTL values bound the window for stale data, and active invalidation keeps critical keys current after write operations. A robust cache-aside pattern consistently treats Redis as an optional accelerator with a database fallback, never as a hard dependency that paralyzes the whole application on failure.

Anyone running the cache-aside pattern in production should plan for hit rate and latency distribution monitoring from the start, guard against cache stampede with locking mechanisms, and prevent cache penetration by caching negative results. These three additions are what separates a production ready cache-aside pattern from a naive implementation that quickly hits its limits under real load.

Implementing the cache-aside pattern correctly, the essentials at a glance

Lazy Loading

Data only enters the cache once requested. Check GET, load from the database on a miss, cache with SET plus TTL.

Cache Miss Handling

Locking against cache stampede, short negative TTL against cache penetration for non existing records.

Invalidation

DEL after a successful database commit, never before. Versioned keys for dependent cache entries.

Error Handling

Catch Redis failures and fall back to the database. Short connection timeout of 100 to 200 ms.

11. FAQ: Implementing the Cache-Aside Pattern

1Cache-aside vs. read-through?
In cache-aside the application fills the cache itself. In read-through an abstraction layer does that transparently.
2How long should the TTL be?
Depends on change frequency. Volatile data short, static data can stay cached noticeably longer.
3What is cache stampede?
Many parallel requests hit an expired key at once. A distributed lock with SET NX EX mitigates it.
4Cache non existing records?
Yes, briefly and with a sentinel value, otherwise cache penetration threatens through repeated requests for non existing IDs.
5What if Redis fails?
Catch connection errors and fall back to the database. Redis always remains an optional accelerator.
6Order for write operations?
Write the database first, then delete the cache key. The reverse order risks an inconsistent in-between state.
7How do I measure the benefit?
Via hit rate from INFO stats and the latency difference between cache hit and cache miss.
8Predis or phpredis?
Logic is identical. phpredis is a C extension with lower overhead, Predis a pure PHP library without an extension.
9Managing dependent cache entries?
Versioned key namespaces, a version increment implicitly invalidates all dependent entries.
10Suitable for write heavy systems?
Only to a degree. Under high write frequency invalidation becomes the bottleneck, write-through or write-back are often better.