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:
GET customer:3
# (nil) -> cache miss; read the customer from the database, then:
SET customer:3 "{\"id\":3,\"name\":\"Fabrikam, Inc.\"}" EX 300EX 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
# 1. UPDATE the database row (in your application code)
# 2. Then remove the cached copy:
DEL customer:3The 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.
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:
- Validate the request and check an idempotency key.
- Write to the database and commit the transaction, recording the intended cache update in a durable outbox table in the same transaction.
- Update the Redis key with the new value and a version value such as a rowversion or ETag, plus a TTL.
- 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.
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 ascustomer: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:
CONFIG SET maxmemory 100mb
CONFIG SET maxmemory-policy allkeys-lruThe 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.
Measure the hit ratio
Use INFO stats and compute the share of accesses served from the cache:
INFO stats
# keyspace_hits / (keyspace_hits + keyspace_misses) * 100If 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.
maxmemoryand 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.
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/
Comments
Questions and tips are welcome. Comments are checked by the EpiFive team before they appear; please do not share passwords, phone numbers or other private details.