MemoryCache in C#: How to Cache Data Efficiently

This article provides an overview of .NET's MemoryCache, detailing how it operates within application memory and outlining the most efficient ways to store, retrieve, and evict cached data in C#. By understanding expiration strategies, size restrictions, and thread-safe retrieval patterns, developers can optimize application performance while avoiding excessive memory consumption.

What is MemoryCache?

MemoryCache is an in-memory key-value store provided by .NET under the Microsoft.Extensions.Caching.Memory namespace. It runs inside the hosting process, making data retrieval extremely fast since it does not incur network serialization or round-trip overhead like distributed caches (e.g., Redis). However, because it shares memory with the application, unmanaged growth can lead to OutOfMemoryException errors, making efficient cache management essential.

Registering and Injecting MemoryCache

To use MemoryCache in modern .NET applications, register it in the dependency injection container:

builder.Services.AddMemoryCache();

Inject the IMemoryCache interface into your service or controller:

public class ProductService
{
    private readonly IMemoryCache _cache;

    public ProductService(IMemoryCache cache)
    {
        _cache = cache;
    }
}

Efficient Data Retrieval with GetOrCreateAsync

The most efficient and safe pattern for accessing cached data is GetOrCreateAsync. This pattern handles checking for the key, computing the value if missing, and storing the result in a single, clean operation:

public async Task<Product> GetProductAsync(int id)
{
    string cacheKey = $"product:{id}";

    return await _cache.GetOrCreateAsync(cacheKey, async entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
        entry.SlidingExpiration = TimeSpan.FromMinutes(2);
        entry.Size = 1;

        return await FetchProductFromDatabaseAsync(id);
    });
}

Strategies for Efficient Cache Storage

1. Combine Absolute and Sliding Expiration

A common pitfall is relying solely on SlidingExpiration, which resets the expiration clock every time an item is read. If an item is read continuously, it will never expire, leading to stale data.

Combine SlidingExpiration with AbsoluteExpirationRelativeToNow to ensure frequently accessed data stays in the cache while forcing a refresh periodically:

var cacheOptions = new MemoryCacheEntryOptions()
    .SetSlidingExpiration(TimeSpan.FromMinutes(5))
    .SetAbsoluteExpiration(TimeSpan.FromHours(1));

2. Enforce Size Limits to Prevent Memory Leaks

By default, MemoryCache has no size limit. To prevent uncontrolled memory growth, configure a global SizeLimit when registering the service:

builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 1024; // Represents logical units (e.g., number of entries or estimated megabytes)
});

When a SizeLimit is set, every entry added to the cache must explicitly declare its size using .SetSize():

var entryOptions = new MemoryCacheEntryOptions()
    .SetSize(1) // Uses 1 unit of the 1024 limit
    .SetAbsoluteExpiration(TimeSpan.FromMinutes(30));

_cache.Set(cacheKey, product, entryOptions);

3. Set Cache Item Priority

When the cache reaches its size limit, it evicts items based on priority. Use CacheItemPriority to guide eviction decisions:

var entryOptions = new MemoryCacheEntryOptions()
    .SetPriority(CacheItemPriority.High)
    .SetSize(1);

Use CacheItemPriority.NeverRemove sparingly, as it bypasses eviction during memory pressure.

4. Cache Keys and Value Types

Keep cache keys short, structured, and consistent (e.g., entity:id). Avoid caching large object graphs that duplicate common data across entries. When possible, cache serialized byte arrays or lightweight Data Transfer Objects (DTOs) rather than live database entities with active change trackers.