Skip to content

// Data & Databases · 07

Cache-Aside

Same data read repeatedlyleads toServed from cache first

Check the cache first; on a miss, load from the DB and store it.

How it fits together

Cache-Aside structureProduct APIGetProductById(id)products DBsource of truthCacheproduct:{id}TTL 10 min + jitter«client»Product update1 GET (miss)2 SELECT3 SET + TTL4 UPDATE5 DEL key
uses / callscreates· numbered steps replay in order
  • 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 readExpand

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.