What Is Cache-Aside?

By Girlway — Published on
0 Likes
0 Dislikes
Cache-Aside Pattern
Cache-Aside Pattern

Cache-aside is a caching pattern where the application manages the cache explicitly. Reads check the cache first, load data from the database on a cache miss, and then place that data into the cache for future requests.

The cache does not automatically load or persist application data. The application decides when to read, populate, invalidate, and refresh cached entries, making cache-aside one of the simplest and most widely applicable caching strategies.

Table of Contents

Why Cache-Aside Exists

Databases are usually designed for durability, querying, transactions, and persistent storage. Serving every application read directly from the database can become expensive when the same data is requested repeatedly.

Consider a product page receiving thousands of requests per minute:

GET /products/8472
GET /products/8472
GET /products/8472
GET /products/8472

If every request executes the same database query, the database repeatedly performs work for data that may change only a few times per day.

A cache can keep the product in memory:

Application → Cache → Product

Most reads can then avoid the database entirely.

Cache-aside provides a simple rule: the application uses the cache when possible and falls back to the database when necessary.

How Cache-Aside Works

The application communicates independently with both the cache and the database.

How Cache-Aside Works
How Cache-Aside Works

The cache is not placed transparently between the application and database. Application code controls the flow.

Cache Hit

When the requested value already exists in the cache, the application returns it without querying the database.

Application → Cache
               |
               └→ HIT → Return value

A simplified Python implementation looks like:

def get_product(product_id: int):
    key = f"product:{product_id}"

    cached = cache.get(key)

    if cached is not None:
        return deserialize(cached)

    return load_product(product_id, key)

This is the fast path. For frequently accessed data, most requests should ideally stop here.

Cache Miss

If the value is missing, the application queries the database.

Application → Cache → MISS
      |
      └→ Database → Value
             |
             └→ Populate Cache

The full implementation becomes:

def get_product(product_id: int):
    key = f"product:{product_id}"

    cached = cache.get(key)

    if cached is not None:
        return deserialize(cached)

    product = database.get_product(product_id)

    if product is not None:
        cache.set(
            key,
            serialize(product),
            ttl=3600,
        )

    return product

The first request pays the database cost. Later requests can use the cached copy until it expires or is invalidated.

This behavior is also called lazy loading because data enters the cache only when something actually requests it.

The Cache-Aside Write Path

Cache-aside primarily defines the read path, but writes need a consistency strategy as well.

A common approach is:

  1. update the database;
  2. delete the corresponding cache entry.
def update_product(product_id: int, changes: dict):
    product = database.update_product(
        product_id,
        changes,
    )

    cache.delete(f"product:{product_id}")

    return product

After invalidation, the next read misses the cache and loads the current value from the database.

Write
  ↓
Update Database
  ↓
Delete Cache Entry

Next Read
  ↓
Cache Miss
  ↓
Read Database
  ↓
Populate Cache

This keeps the database authoritative while treating cached data as disposable.

Updating the database before invalidating the cache is important. Deleting the cache first can create a race where another request repopulates it with the old database value before the write commits.

Why Delete Instead of Update?

After changing the database, it may seem more efficient to update the cached value directly:

Update Database
      ↓
Update Cache

This can work, but invalidation is often simpler.

A cached representation may not map directly to one database row. A product update could affect:

  • the product object;
  • category listings;
  • search results;
  • pricing summaries;
  • recommendation data;
  • aggregated inventory views.

Reconstructing every affected cached value correctly during the write can become complex.

Deleting an entry instead lets the normal read path rebuild it from authoritative data.

Invalidation also avoids some stale-write races where an older request finishes its cache update after a newer database write.

This does not mean deletion is always better. Frequently read data may benefit from proactive cache updates. That is one reason write-through and related strategies exist.

Cache-Aside vs Write-Through

The main difference is what happens when data changes.

With cache-aside, the application commonly invalidates the cached value:

Database update → Delete cache entry

With write-through, the write path keeps the cache populated with the new value:

Database update → Update cache entry
Property Cache-Aside Write-Through
Cache population On demand after a miss During writes and possibly reads
Typical write behavior Update DB, invalidate cache Update DB and cache
First read after invalidation Cache miss Usually cache hit
Unused written data Does not need to enter cache May consume cache space

Cache-aside is attractive when the application wants the cache to contain primarily data that is actually being read.

Cache-Aside vs Write-Through vs Write-Behind provides a broader comparison of these caching strategies.

Cache-Aside vs Write-Behind

Write-behind changes the role of the cache more significantly.

In a cache-aside architecture, durable storage remains directly involved in writes:

Application → Database
      |
      └→ Invalidate Cache

With write-behind, the application can write to the cache and allow persistence to happen asynchronously:

Application → Cache → Success
                  |
                  └→ Database later

Write-behind can reduce write latency and batch storage operations, but acknowledged data may temporarily exist only in the caching layer or its persistence mechanism.

Cache-aside generally has a simpler durability model because the database remains the source of truth and cached values can be discarded and reconstructed.

Stale Data and Race Conditions

Cache-aside does not provide strong consistency automatically. The cache and database are independent systems, and operations can interleave in unexpected ways.

Read-Write Race

Consider an entry that is currently absent from the cache.

Request A starts reading the product:

A → Cache miss
A → Read database → price=$100

Before A stores that result, Request B updates the product:

B → Database update → price=$90
B → Delete cache entry

The cache is already empty, so the deletion changes nothing.

Request A then resumes:

A → Cache set → price=$100

The final state is:

Database → $90
Cache    → $100

A stale value has been reintroduced after the write completed.

This race may be rare, but high-throughput systems eventually encounter rare interleavings.

Possible protections include short TTLs, versioned cache values, delayed invalidation, change events, or designs where strict freshness does not depend on cache-aside alone.

Delayed Invalidation

One technique is to invalidate the key again after a short delay.

Update Database
      ↓
Delete Cache
      ↓
Wait briefly
      ↓
Delete Cache again

The second invalidation can remove a stale value that was repopulated by an overlapping read.

This technique is sometimes called delayed double deletion.

It reduces a particular race window but introduces additional timing assumptions and operational complexity. It should not be treated as a substitute for understanding the application's consistency requirements.

For broader invalidation strategies and their trade-offs, see Cache Invalidation Strategies for Production Systems.

Cache Expiration and TTL

Cache-aside entries usually have a time-to-live.

cache.set(
    "product:8472",
    serialize(product),
    ttl=3600,
)

After one hour, the entry expires and the next request reloads it from the database.

TTL serves several purposes:

  • limits how long accidentally stale values can survive;
  • removes entries that are no longer used;
  • allows the cache to refresh data periodically;
  • reduces dependence on perfect invalidation.

TTL selection is a trade-off.

A very short TTL increases database traffic because entries are frequently reloaded. A very long TTL improves hit rate but increases the potential lifetime of stale data when invalidation fails.

Different data usually deserves different TTLs.

Feature configuration → 5 minutes
Product details       → 1 hour
Country metadata      → 24 hours

Adding small random variation to TTLs can also prevent large groups of entries created together from expiring at exactly the same moment.

Cache Stampedes

A popular cache entry can create a sudden database spike when it expires.

Suppose a product receives 5,000 requests per second.

Its cache entry expires:

Request 1 → MISS → Database
Request 2 → MISS → Database
Request 3 → MISS → Database
...
Request N → MISS → Database

Many requests observe the miss before the first request has time to rebuild the cache.

The database receives a burst of identical queries.

This is a cache stampede.

A common protection is request coalescing or locking around cache reconstruction:

Requests → Cache Miss
              |
              ├→ One request loads DB
              |
              └→ Other requests wait/retry
                         |
                         ↓
                   Cache populated

Other techniques include TTL jitter, stale-while-revalidate behavior, proactive refresh, and serving slightly stale values while one process refreshes the entry.

Preventing Cache Stampedes and Hot Keys covers these failure modes in more detail.

Negative Caching

Cache-aside implementations often cache only successful database results:

product = database.get_product(product_id)

if product is not None:
    cache.set(key, serialize(product), ttl=3600)

This creates a problem when clients repeatedly request data that does not exist.

GET /products/999999 → DB → Not Found
GET /products/999999 → DB → Not Found
GET /products/999999 → DB → Not Found

Every request misses the cache and reaches the database.

Negative caching temporarily stores the fact that the object does not exist.

if product is None:
    cache.set(
        key,
        "NOT_FOUND",
        ttl=60,
    )

Subsequent requests can return the missing result without another database query.

The TTL for negative entries is often shorter because the resource may be created soon.

Negative caching is particularly useful against repeated lookups for invalid IDs, crawler traffic, missing configuration, and requests for recently deleted resources.

Production Design Example

Consider a Product Service using PostgreSQL as durable storage and Redis as an application cache.

Products are read frequently and updated relatively infrequently, making them good cache candidates.

The read implementation is:

def get_product(product_id: int):
    key = f"product:{product_id}"

    cached = redis.get(key)

    if cached == b"NOT_FOUND":
        return None

    if cached is not None:
        return deserialize(cached)

    product = repository.get(product_id)

    if product is None:
        redis.setex(key, 60, "NOT_FOUND")
        return None

    redis.setex(
        key,
        3600,
        serialize(product),
    )

    return product

The database is queried only after a cache miss.

The update path commits to PostgreSQL first:

def update_product(product_id: int, changes: dict):
    product = repository.update(
        product_id,
        changes,
    )

    try:
        redis.delete(f"product:{product_id}")
    except RedisError:
        record_cache_invalidation_failure(product_id)

    return product

The database remains authoritative. Redis can be cleared completely without losing persistent application data.

A Redis outage changes performance rather than data durability:

Redis healthy:
Application → Redis → fast response

Redis unavailable:
Application ─────────→ PostgreSQL → response

This fallback needs capacity planning. If Redis normally absorbs 95% of product reads, a cache outage can suddenly send approximately 20 times the normal read traffic to PostgreSQL.

The database and application therefore need protection against cache-loss scenarios through connection limits, rate limiting, graceful degradation, replicas, or other workload controls.

For popular products, the service can also use request coalescing so one request reconstructs an expired entry while concurrent requests wait briefly or use stale data.

The result is a cache that improves normal read performance without becoming the authoritative store.

Monitoring Cache-Aside

A cache-aside architecture should be monitored as part of the complete read path rather than as an isolated Redis or Memcached component.

Metric What It Reveals
Cache hit ratio How many reads avoid the database
Cache miss rate How much fallback traffic reaches storage
Cache latency Whether the cache is still providing a fast path
Database reads after misses The downstream cost of cache misses
Evictions Whether memory pressure is removing active entries
Invalidation failures Potential stale-data risk
Misses by key or entity type Hot keys and poor caching candidates

Hit ratio should be interpreted with workload context.

A 95% hit rate can be excellent, but the remaining 5% may still represent thousands of database queries per second. A 60% hit rate may be completely acceptable for a low-volume workload.

Latency distributions and database impact are usually more useful than optimizing hit ratio as an isolated number.

Common Cache-Aside Mistakes

The basic cache-aside algorithm is small, but several production problems appear around it.

  • Deleting the cache before updating the database. Another reader can repopulate the cache with the old database value.
  • No expiration. Failed invalidation can leave stale entries indefinitely.
  • Using the same TTL for every data type. Different freshness and access patterns require different policies.
  • Ignoring cache stampedes. Popular entries can overload the database when they expire.
  • Not caching missing values. Repeated requests for nonexistent resources can continuously hit storage.
  • Caching huge objects indiscriminately. Serialization, network transfer, and memory costs can exceed the benefit.
  • Assuming a cache outage is harmless. The database may be unable to absorb the sudden miss traffic.
  • Ignoring invalidation failures. Database writes can succeed while stale cached values remain available.
  • Caching highly volatile data with long TTLs. High hit rates are not useful if responses are frequently incorrect.

Cache-aside works best when the cache is treated as a performance optimization with explicit behavior for misses, invalidation failures, expiration, and complete cache loss.

When to Use Cache-Aside

Cache-aside is a strong fit for read-heavy workloads where data can be loaded from an authoritative store on demand.

Typical examples include:

  • product catalogs;
  • user profiles;
  • configuration;
  • reference data;
  • content metadata;
  • expensive database query results.

It is particularly useful when only a subset of a large dataset is accessed frequently. Because values enter the cache lazily, inactive data does not consume cache capacity merely because it was written to the database.

Cache-aside may be less appropriate when every write must immediately produce a cached representation, when strict read-after-write consistency is required, or when the application should not contain cache-management logic.

For a broader view of where caching belongs in a production architecture, see Caching Best Practices for Production Systems.

Conclusion

Cache-aside keeps the application in control of caching. Reads check the cache first, cache misses fall back to the database, and successful database results are stored for future requests. Writes commonly update the database and invalidate the affected cache entries.

The pattern is simple, flexible, and well suited to read-heavy workloads, but it does not remove consistency problems. TTL selection, invalidation failures, concurrent reads and writes, cache stampedes, negative caching, and database capacity during cache outages all need deliberate handling.

The core principle is straightforward: the database remains authoritative, while the cache contains disposable copies of data that the application can rebuild whenever necessary.

Comments (0)