# Caching Strategies: Cache-Aside, Write-Through and Invalidation with Redis

> Choose between cache-aside and write-through caching with Redis. Covers TTLs, invalidation order, eviction policies, stale reads, hit-ratio monitoring and Redis commands you can run today.

Source: https://www.epifive.com/blog/caching-strategies-cache-aside-redis · Published 2026-10-25 · EpiFive

## Key takeaways

- Cache-aside loads data on demand and invalidates on write; it does not guarantee consistency between the cache and the data store.
- On writes, update the data store first and then delete the cached key, because the reverse order can put stale data back in the cache.
- Write-through updates the store and cache together, so readers see new values right after a write, at the cost of a more complex write path.
- Set a TTL on every cached item and choose a Redis eviction policy such as allkeys-lru; check keyspace_hits and keyspace_misses to measure the hit ratio.

Use cache-aside when reads dominate and a short window of stale data is acceptable; use write-through when readers must see a new value immediately after a successful write. In both cases keep the database as the source of truth, give every cached item a time to live (TTL), and choose a Redis eviction policy on purpose. This guide explains each strategy, shows the Redis commands involved and covers invalidation order, which is where most caching bugs come from.

## Who this is for

This is for developers adding Redis, including Azure Managed Redis, in front of a relational database or API. By the end you will know which strategy fits each data type, how to order writes and invalidations, how to configure expiry and eviction, and how to check that the cache is helping.

## The strategies at a glance

| Strategy | Read path | Write path | Freshness after a write | Complexity |
| --- | --- | --- | --- | --- |
| Cache-aside | App checks cache, loads from the store on a miss, then fills the cache | App writes the store, then deletes the cached key | Next read after the delete sees new data; a reader may briefly see a miss or stale data | Low |
| Write-through | App reads the cache; falls back to the store on a miss | One operation updates the store and the cache | New value visible right after a successful write | Higher |
| Async refresh | App reads the cache | Store is written; cache is updated later through a queue or outbox | Cache lags the write | Medium |

Microsoft's cache-aside article describes how some caching systems offer read-through, write-through or write-behind natively. Redis does not do that for you with your own database, so your application code emulates it. Cache-aside is the emulation of read-through; write-through is something you build around your write path.

## Cache-aside step by step

The Azure Architecture Center describes the flow in three steps: the application tries to read the item from the cache; on a cache miss it retrieves the item from the data store; it then adds the item to the cache and returns it. When the application updates data, it writes the change to the data store and then invalidates the corresponding cache item. The item is reloaded on the next read.

### Read path with Redis commands

In `redis-cli` terms, a read is a `GET`; the fill after a miss is a `SET` with an expiry:

```bash
GET customer:3
# (nil)  -> cache miss; read the customer from the database, then:
SET customer:3 "{\"id\":3,\"name\":\"Fabrikam, Inc.\"}" EX 300
```

`EX 300` expires the key after 300 seconds. The Redis documentation notes that `SET` discards any previous TTL on the key unless you pass `KEEPTTL`, and that `EX` and `PX` are mutually exclusive with the other expiration options. Always set an expiry when you fill the cache, as Microsoft's example does with a default of five minutes, so that an entry updated by another process cannot stay stale forever.

### Write path: update first, invalidate second

```bash
# 1. UPDATE the database row (in your application code)
# 2. Then remove the cached copy:
DEL customer:3
```

The order matters. Microsoft's guidance says to update the data store before removing the item from the cache. If you remove the cached item first, a client can fetch the item in the window before the data store is updated. That fetch misses, reads the outdated row from the database and adds it back to the cache, leaving stale data until the TTL ends.

### Application code

The following C# method mirrors the structure of Microsoft's StackExchange.Redis example. It tries the cache, falls back to the store on a miss, avoids caching null and sets an expiry.

```csharp
public async Task<Customer?> GetCustomerAsync(int id)
{
    var key = $"customer:{id}";
    var cache = _connection.GetDatabase();

    var json = await cache.StringGetAsync(key);
    if (json.HasValue)
        return JsonSerializer.Deserialize<Customer>(json!);

    var customer = await _repository.LoadCustomerAsync(id);   // database call
    if (customer is not null)
    {
        await cache.StringSetAsync(key, JsonSerializer.Serialize(customer),
                                   TimeSpan.FromMinutes(5));
    }
    return customer;
}

public async Task UpdateCustomerAsync(Customer customer)
{
    await _repository.UpdateCustomerAsync(customer);          // 1. database first
    await _connection.GetDatabase()
                     .KeyDeleteAsync($"customer:{customer.Id}"); // 2. then invalidate
}
```

Share one `ConnectionMultiplexer` for the whole application, as the Microsoft example does with a lazily initialised static instance, instead of creating one per request.

### What cache-aside does not guarantee

Microsoft is explicit that cache-aside does not guarantee consistency between the data store and the cache. An external process can change the store at any time, and the change won't show up until the item is loaded again. Also, between a write and the next read, a reader can experience a miss or briefly see stale data. Use cache-aside for data that is read often and changes rarely, and avoid it for data that is sensitive or security related, because the article warns that storing such data in a shared cache may be inappropriate.

## Write-through for read-after-write freshness

Write-through caching updates the data store and the cache in the same write operation, so readers see the new value immediately after a successful write. Microsoft has a reference architecture for application-managed write-through using Azure Managed Redis and Azure SQL Database, and its design is a useful template:

1. Validate the request and check an idempotency key.
2. Write to the database and commit the transaction, recording the intended cache update in a durable outbox table in the same transaction.
3. Update the Redis key with the new value and a version value such as a rowversion or ETag, plus a TTL.
4. Mark the outbox entry completed and return success.

If the database commits but the Redis update fails, the database remains authoritative and a repair process applies the pending outbox entries to Redis idempotently. The article says this is not an atomic distributed transaction across SQL and Redis, and the API contract must not treat the write as normally completed until Redis is updated; it suggests responses such as `202 Accepted` or `503 Service Unavailable` with `Retry-After`. It also recommends storing a version with each cached value so an older retry cannot overwrite a newer entry, and using TTLs as a backstop against stale data from partial failures. The outbox technique is covered in [Transactional Outbox Pattern](https://www.epifive.com/blog/transactional-outbox-pattern-reliable-events).

Microsoft advises using write-through only for read-heavy paths where clients need fresh values right after writes. For data that is rarely read after writes, highly volatile or unlikely to give a high hit ratio, read from the database or use a simpler pattern.

## Choose TTLs and keys deliberately

- **TTL length.** Too short and the application keeps reloading from the store; too long and data goes stale. Match expiry to how often the data changes and how stale a reader may see it.
- **Key design.** Build keys deterministically from the entity and, where relevant, the tenant or user scope, for example `tenant:{tenantId}:customer:{customerId}`. Add a schema version, such as `customer:v2:3`, when the cached format changes.
- **No sensitive data without a design.** Include authorization scope in keys for tenant or user data, and enforce authorization before returning cached values.
- **Per-item settings.** Microsoft notes that expensive-to-load items can deserve longer TTLs than cheap ones.

## Configure eviction

A cache has a memory limit, and Redis needs a policy for what to do when it is reached. Set `maxmemory` and `maxmemory-policy`:

```bash
CONFIG SET maxmemory 100mb
CONFIG SET maxmemory-policy allkeys-lru
```

The Redis documentation lists the policies: `noeviction` returns errors for commands that add data; `allkeys-lru`, `allkeys-lfu`, `allkeys-lrm` and `allkeys-random` evict any key; the `volatile-*` variants consider only keys with a TTL; and `volatile-ttl` evicts the keys with the shortest remaining TTL. The `volatile-*` policies behave like `noeviction` if no keys have an expiry. The documentation suggests `allkeys-lru` as a good default when you have no reason to prefer another policy, since a subset of keys is usually read far more often than the rest. Managed services may expose these settings differently, so check your service's documentation. For a deeper look at out-of-memory behaviour, read [Redis OOM and maxmemory eviction policy](https://www.epifive.com/blog/redis-oom-maxmemory-eviction-policy).

## Measure the hit ratio

Use `INFO stats` and compute the share of accesses served from the cache:

```bash
INFO stats
# keyspace_hits / (keyspace_hits + keyspace_misses) * 100
```

If the ratio is lower than you expect, look at `evicted_keys` and `expired_keys` in the same output. High evictions suggest the memory limit or policy is wrong; high expirations suggest TTLs are too short. Microsoft also notes that cache-aside may not be worth it when most requests miss, because the overhead of checking and filling the cache can outweigh the benefit.

## Troubleshooting

**Users see old data after an update.** Check the order: database write first, then `DEL`. Also check for writers that bypass your code path, such as batch jobs or manual SQL, and rely on TTLs to bound that staleness.

**The database spikes when a popular key expires.** Many requests miss at once and all reload the same item. Reduce the effect by staggering TTLs, by pre-loading hot keys, or by allowing only one request to rebuild a key at a time. If you use a Redis key as a rebuild lock, note that the `SET key value NX EX seconds` form is described in the Redis docs as a simple lock that is discouraged in favour of Redlock for stronger guarantees.

**Memory grows until commands fail with out-of-memory errors.** With `noeviction`, Redis refuses writes at the limit. Switch to an eviction policy suitable for caches and make sure every cached key has a TTL.

**Hit ratio is poor.** Look for keys that include volatile parts such as timestamps, for items that are rarely read twice, and for TTLs shorter than the typical re-read interval.

**Each app instance sees different data.** In-process caches are private to each instance. Microsoft recommends a shared or distributed cache when several instances must agree.

## Checklist

- Database is the source of truth; cache holds copies.
- Cache-aside for read-heavy, tolerant data; write-through where read-after-write is required.
- Update the database first, then delete the cache key.
- Every cached key has a TTL.
- `maxmemory` and an eviction policy are set deliberately.
- Hit ratio is tracked from `INFO stats`.
- Sensitive data is excluded or keyed by authorization scope.

If you are provisioning Azure's managed offering, see [Azure Managed Redis migration from Azure Cache for Redis](https://www.epifive.com/blog/azure-managed-redis-migration-from-azure-cache).

## References

- Microsoft Learn, Cache-Aside pattern: https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside
- Microsoft Learn, Write-through caching with Azure Managed Redis and Azure SQL Database: https://learn.microsoft.com/en-us/azure/architecture/databases/architecture/write-through-caching-azure-sql-managed-redis
- Redis documentation, SET: https://redis.io/docs/latest/commands/set/
- Redis documentation, Key eviction: https://redis.io/docs/latest/develop/reference/eviction/

## Frequently asked questions

### What is the cache-aside pattern?

The application checks the cache first. On a miss it reads the data store, adds the item to the cache and returns it. On a write it updates the data store and then invalidates the cached item, so the next read reloads fresh data.

### Should I delete the cache entry before or after updating the database?

After. Microsoft's cache-aside guidance says to update the data store first and then remove the cached item. If you delete first, another request can miss, read the old row from the database and put the outdated value back in the cache.

### When is write-through better than cache-aside?

When readers need to see a new value immediately after a successful write. Write-through updates the data store and the cache in the same write operation. Cache-aside is simpler but leaves a short window of a miss or stale data after each write.

### Which Redis eviction policy should I use for a cache?

The Redis documentation suggests allkeys-lru as a good default when you have no reason to prefer another policy, because a subset of keys usually gets most of the reads. Use allkeys-lfu or volatile-ttl only if your access pattern or TTL strategy calls for them.

