// Data & Databases · 07
Cache-Aside
Check the cache first; on a miss, load from the DB and store it.
How it fits together
- Product API uses / calls Cache (1 GET (miss))
- Product API uses / calls products DB (2 SELECT)
- Product API creates Cache (3 SET + TTL)
- Product update uses / calls products DB (4 UPDATE)
- Product update uses / calls Cache (5 DEL key)
Spot it
- The same query runs thousands of times a minute
- Data changes rarely but is read constantly
- The database is the bottleneck on reads
It costs
Reads get fast, but data can be stale and invalidation is now your job.
In code
public sealed class ProductCache(IDistributedCache cache, IProductStore store){ public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct) { var key = $"product:{id}"; var cached = await cache.GetStringAsync(key, ct); if (cached is not null) { return JsonSerializer.Deserialize<Product>(cached); } var product = await store.FindAsync(id, ct); if (product is null) { return null; } var ttl = TimeSpan.FromMinutes(10 + Random.Shared.Next(0, 3)); var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }; await cache.SetStringAsync(key, JsonSerializer.Serialize(product), options, ct); return product; } public async Task UpdateAsync(Product product, CancellationToken ct) { await store.SaveAsync(product, ct); await cache.RemoveAsync($"product:{product.Id}", ct); }}Go deeperThe full explanation and what to ask for · 1 min readExpandCollapse
The application owns the logic: read the cache; on a miss, read the database and write the result to the cache with a TTL. On update, write the database and then delete the key rather than updating it, so the next read repopulates from the source.
The classic failure is a cache stampede: a hot key expires and hundreds of requests hit the database at once. Coalesce concurrent misses (a per-key lock, or a cache that coalesces loads for you), add jitter so keys do not expire together, or refresh hot keys early.
Delete-on-write still leaves a race where a slow reader puts an old value back, so keep a TTL as a backstop. Cache what is read far more than written, and keep the cache optional: the app must still work, slower, when it is down.
Ask for it
- cache hit ratio
- TTL with jitter
- invalidation on write
- cache stampede
- request coalescing
Add cache-aside with a cache such as Redis for GetProductById: key product:{id}, a 10 minute TTL plus jitter, delete the key after a successful update, and coalesce concurrent misses so a cold key causes one database query. Expose the hit ratio as a metric.