Learnixo
Back to blog
AI Systemsintermediate

IMemoryCache — In-Process Caching in ASP.NET Core

IMemoryCache in depth: registration, absolute and sliding expiration, cache entry options, size limits, eviction callbacks, and the production patterns for safe in-process caching.

Asma Hafeez KhanMay 16, 20265 min read
CachingIMemoryCacheASP.NET Core.NETPerformance
Share:𝕏

What IMemoryCache Is

IMemoryCache stores cached values in the process memory of the running application. It is fast (no network, no serialization), simple to set up, and perfect for single-instance applications or caching values that differ per instance.

IMemoryCache:
  Storage:   process memory (RAM on the current server)
  Speed:     nanoseconds (dictionary lookup)
  Lifetime:  process lifetime — cache lost on restart/redeploy
  Sharing:   NOT shared across multiple instances

When to use:
  ✓ Single-instance apps or Azure App Service with 1 instance
  ✓ Values that do not need consistency across instances
  ✓ Small-to-medium datasets (do not fill your process memory)
  ✓ Reference data: drug formulary, hospital code lookups
  ✗ Do not use when 2+ instances must share state

Registration

C#
// Program.cs
builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 1000;  // optional: cap the number of entries
    // Without SizeLimit, no eviction based on count — only expiration
});

Basic Get/Set

C#
public sealed class DrugFormularyCache
{
    private readonly IMemoryCache _cache;
    private readonly DrugRepository _repo;

    public DrugFormularyCache(IMemoryCache cache, DrugRepository repo)
        => (_cache, _repo) = (cache, repo);

    public async Task<List<Drug>> GetFormularyAsync(CancellationToken ct)
    {
        // GetOrCreateAsync: get if exists, create if not — atomically
        return await _cache.GetOrCreateAsync("formulary", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
            entry.SlidingExpiration               = TimeSpan.FromMinutes(15);
            entry.Priority                        = CacheItemPriority.High;

            return await _repo.GetAllActiveAsync(ct);
        }) ?? new List<Drug>();
    }
}

Expiration Types

Absolute expiration:
  entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
  Entry expires 1 hour after it was created
  Good for: data that changes on a schedule (nightly refresh)

Sliding expiration:
  entry.SlidingExpiration = TimeSpan.FromMinutes(15)
  Entry expires if not accessed for 15 minutes
  Resets on each access
  Good for: user-session data, per-user preferences

Combined (recommended):
  AbsoluteExpirationRelativeToNow = 1 hour
  SlidingExpiration               = 15 minutes
  Entry expires after 1 hour regardless, or 15 min of inactivity
  Prevents cache entries from living forever via constant access

Size Limit and Eviction

C#
builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 500;  // max 500 size units
});

// Each entry must declare its size when SizeLimit is set
_cache.GetOrCreateAsync("formulary", entry =>
{
    entry.Size = 1;  // counts as 1 unit toward the 500 limit
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
    // ...
});

// Priority affects which entries are evicted first when memory pressure hits
entry.Priority = CacheItemPriority.High;    // evict last
entry.Priority = CacheItemPriority.Normal;  // default
entry.Priority = CacheItemPriority.Low;     // evict first
entry.Priority = CacheItemPriority.NeverRemove;  // never evicted by pressure

Eviction Callbacks

C#
// Called when an entry is evicted — useful for logging or refresh
entry.RegisterPostEvictionCallback((key, value, reason, state) =>
{
    var logger = (ILogger)state!;
    logger.LogInformation(
        "Cache entry {Key} evicted due to {Reason}",
        key, reason);
    // reason: Expired, Replaced, Capacity, Removed
});

Manual Get and Set

C#
// Check if exists
if (_cache.TryGetValue("formulary", out List<Drug>? cached))
    return cached!;

// Set manually
_cache.Set("formulary", drugs, new MemoryCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});

// Remove manually (invalidation)
_cache.Remove("formulary");

Stampede Protection (Cache Miss Thundering Herd)

C#
// Problem: 100 concurrent requests all miss the cache simultaneously
// All 100 hit the database at the same time

// Solution: lock or use SemaphoreSlim to allow only one to build
private static readonly SemaphoreSlim _lock = new(1, 1);

public async Task<List<Drug>> GetFormularyAsync(CancellationToken ct)
{
    if (_cache.TryGetValue("formulary", out List<Drug>? cached))
        return cached!;

    await _lock.WaitAsync(ct);
    try
    {
        // Double-check after acquiring lock
        if (_cache.TryGetValue("formulary", out cached))
            return cached!;

        var drugs = await _repo.GetAllActiveAsync(ct);
        _cache.Set("formulary", drugs, TimeSpan.FromHours(1));
        return drugs;
    }
    finally
    {
        _lock.Release();
    }
}

Production issue I've seen: A drug formulary endpoint had a 500ms DB query. At shift-change time (7 AM), 200 nurses logged in within 60 seconds, the cache expired at the same moment, and all 200 requests hit the database simultaneously. The database CPU spiked to 100% for 2 minutes. SemaphoreSlim protection (or HybridCache) prevents this entirely.


Caching Patterns for Clinical Data

Drug formulary:
  AbsoluteExpiration: 4 hours (formulary updated by pharmacy team, not real-time)
  Key: "formulary:{hospitalId}"
  Invalidate: when pharmacy team saves a formulary change

Ward lookup:
  AbsoluteExpiration: 24 hours (wards rarely change)
  Key: "wards"

Patient in current session:
  SlidingExpiration: 10 minutes (evict if nurse walks away)
  Key: "patient:{patientId}:session:{sessionId}"
  Never cache diagnosis or drug orders — too critical to serve stale

Red Flag / Green Answer

Red Flag: "We cache everything in IMemoryCache in our load-balanced environment with 4 instances."

Each instance has its own in-process cache. An update on instance A invalidates its cache but not instances B, C, or D. Users on different instances see different data. Use IDistributedCache (Redis) for consistency across instances.

Green Answer:

IMemoryCache for reference data that can be stale for an hour (drug formulary, ICD codes). Redis (IDistributedCache or HybridCache) for any data that must be consistent across all instances.


Key Takeaway

IMemoryCache is the fastest cache option: process-local, no serialization, nanosecond access. Use it for reference data in single-instance apps or data that can be stale per-instance. Set absolute AND sliding expiration together to bound entry lifetime. Add stampede protection via SemaphoreSlim or migrate to HybridCache (which has built-in stampede protection). Never use IMemoryCache for cross-instance consistency — that requires a distributed cache.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.