asp.net
cache locking
caching strategies
web development
performance optimization

What is the best way to lock cache in asp.net?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Cache locking in ASP.NET is usually about preventing a cache stampede, where many requests miss the same key and all rebuild the same expensive value at once. The best approach is not a single global lock, but a narrow per-key coordination strategy that keeps concurrency high while ensuring only one request recomputes a given item.

Why cache locking is needed

Consider an endpoint that loads a dashboard from a slow database query. If the cache entry expires and two hundred requests arrive together, each request may run that same expensive query unless you coordinate cache population.

Without locking, the result is:

  • repeated database work
  • slower response time during misses
  • bursty load right when the cache is supposed to help

The goal is not to lock every cache read. Reads should stay cheap. The goal is to lock only the cache fill path.

Lock only the populate path

In ASP.NET Core, the normal pattern is:

  1. try to read from cache
  2. if present, return immediately
  3. if missing, acquire a lock for that key
  4. check the cache again inside the lock
  5. compute and store the value once

The second cache check matters because another request may have filled the value while the current request was waiting.

csharp
1using System;
2using System.Collections.Concurrent;
3using System.Threading;
4using System.Threading.Tasks;
5using Microsoft.Extensions.Caching.Memory;
6
7public sealed class ReportCache
8{
9    private readonly IMemoryCache _cache;
10    private static readonly ConcurrentDictionary<string, SemaphoreSlim> Locks = new();
11
12    public ReportCache(IMemoryCache cache)
13    {
14        _cache = cache;
15    }
16
17    public async Task<string> GetReportAsync(string reportId)
18    {
19        var cacheKey = $"report:{reportId}";
20
21        if (_cache.TryGetValue(cacheKey, out string cached))
22        {
23            return cached;
24        }
25
26        var gate = Locks.GetOrAdd(cacheKey, _ => new SemaphoreSlim(1, 1));
27        await gate.WaitAsync();
28
29        try
30        {
31            if (_cache.TryGetValue(cacheKey, out cached))
32            {
33                return cached;
34            }
35
36            var rebuilt = await LoadReportFromDatabaseAsync(reportId);
37            _cache.Set(cacheKey, rebuilt, TimeSpan.FromMinutes(10));
38            return rebuilt;
39        }
40        finally
41        {
42            gate.Release();
43        }
44    }
45
46    private static async Task<string> LoadReportFromDatabaseAsync(string reportId)
47    {
48        await Task.Delay(250);
49        return $"report-for-{reportId}";
50    }
51}

This design keeps requests for different keys independent. A miss for report:42 does not block a miss for report:99.

Why a global lock is usually wrong

A single lock around all cache writes is easy to implement, but it reduces throughput badly under load. If unrelated cache keys all wait on one shared lock, the cache becomes a bottleneck instead of a performance tool.

Use a global lock only if the cached state is truly global and must be rebuilt as one unit. Most application caches are key-based, so per-key locking is the better default.

Using GetOrCreateAsync carefully

IMemoryCache offers helper methods such as GetOrCreateAsync, but developers often assume that these methods automatically prevent duplicate concurrent factories. That assumption can be risky depending on how the code is structured and what level of coordination you need. If stampede prevention matters, an explicit gate around the expensive factory is clearer and easier to reason about in code review.

A wrapper service can combine both patterns:

csharp
1public async Task<T> GetOrRebuildAsync<T>(
2    string key,
3    Func<Task<T>> factory,
4    TimeSpan ttl)
5{
6    if (_cache.TryGetValue(key, out T cached))
7    {
8        return cached;
9    }
10
11    var gate = Locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
12    await gate.WaitAsync();
13
14    try
15    {
16        if (_cache.TryGetValue(key, out cached))
17        {
18            return cached;
19        }
20
21        var value = await factory();
22        _cache.Set(key, value, ttl);
23        return value;
24    }
25    finally
26    {
27        gate.Release();
28    }
29}

That wrapper gives you one place to standardize cache population rules, metrics, and expiration settings.

Add expiration and failure policy

A lock only solves duplicate recomputation. You still need a policy for expiration and failed rebuilds.

Practical rules:

  • use absolute or sliding expiration intentionally
  • avoid caching exceptions by accident
  • consider short stale windows for expensive data
  • log rebuild duration and cache misses

If the rebuild fails, do not leave the key half-populated. Either return an error or preserve the previous cached value if your application can tolerate slightly stale data.

ASP.NET Framework versus ASP.NET Core

The same idea applies to classic ASP.NET with System.Web.Caching.Cache, but the APIs differ. The important design choice stays the same: do not lock every read, and do not serialize unrelated cache entries behind one monitor. In both platforms, the best lock scope is usually the cache key being rebuilt.

For multi-server deployments, remember that in-memory locks work only inside one process. If several web nodes share the same distributed cache, you may need distributed coordination or a cache provider with built-in single-flight behavior.

Common Pitfalls

Using one global lock for all cache entries is the most common mistake because it silently throttles unrelated requests. Another frequent problem is locking the read path as well as the write path, which removes most of the benefit of caching. Developers also forget the second cache check inside the lock and end up recomputing values unnecessarily. In multi-node systems, in-process locking is often mistaken for cluster-wide coordination, which it is not. Finally, caches are sometimes filled with very long-running database calls and no timeout or instrumentation, so failures remain hidden until traffic spikes expose them.

Summary

  • The best ASP.NET cache locking strategy is usually per-key locking during cache population.
  • Keep cache reads lock-free whenever possible.
  • Recheck the cache after acquiring the lock to avoid duplicate work.
  • Prefer SemaphoreSlim or another narrow gate over one global application lock.
  • Treat multi-server deployments separately because in-memory locks do not coordinate across nodes.
  • Add expiration, logging, and failure handling so cache rebuilds stay observable and safe.

Course illustration
Course illustration

All Rights Reserved.