Architecture

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.

By EpiFive Editorial TeamReviewed/updated 9 min readHow we check articles
On this page

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

StrategyRead pathWrite pathFreshness after a writeComplexity
Cache-asideApp checks cache, loads from the store on a miss, then fills the cacheApp writes the store, then deletes the cached keyNext read after the delete sees new data; a reader may briefly see a miss or stale dataLow
Write-throughApp reads the cache; falls back to the store on a missOne operation updates the store and the cacheNew value visible right after a successful writeHigher
Async refreshApp reads the cacheStore is written; cache is updated later through a queue or outboxCache lags the writeMedium

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 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

# 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.

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.

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:

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.

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) * 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.

References

Questions people ask

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.

RedisAzure Managed RedisCachingCache-Aside
  1. Monolith vs Microservices for a Small Team: A Decision Guide

    Decide whether a modular monolith is enough or when to split into services, using the Azure Architecture Center's benefits, challenges and domain-analysis guidance.

    Architecture13 min read
  2. Migrate Azure Functions from In-Process to the Isolated Worker Model

    In-process .NET function apps lose support on November 10, 2026. Find affected apps, convert the project, and cut over to the isolated worker with a slot swap.

    Architecture13 min read
  3. Circuit Breaker Pattern with Polly in .NET: Example and Settings

    Add retries and a circuit breaker to HttpClient in .NET with Microsoft.Extensions.Http.Resilience and Polly: default values, custom pipeline code, tuning and BrokenCircuitException fixes.

    Architecture10 min read
  4. Azure Well-Architected Framework: Run a Review with the Five Pillars

    Run an Azure Well-Architected Review on a workload: scope it, work through the five pillars with checklists, weigh tradeoffs, use Azure Advisor and turn findings into a prioritised backlog.

    Architecture8 min read
  5. ERP to Ecommerce Sync Patterns: Polling, Webhooks and Event Queues

    Compare polling, webhooks and queue-based sync for connecting an ERP to an online store, using Business Central webhooks as the worked example, with reconciliation, ordering and duplicate handling.

    Architecture8 min read
  6. Idempotent API Design: Keys, Storage and Retention for Payment-Style Calls

    Design POST endpoints that clients can safely retry: the Idempotency-Key header, a PostgreSQL key table, request fingerprints, concurrent retries, status codes and how long to keep keys.

    Architecture9 min read