Edit

Cache in-memory in ASP.NET Core

By Rick Anderson, John Luo, and Steve Smith

Note

This isn't the latest version of this article. For the current release, see the .NET 10 version of this article.

Caching can significantly improve the performance and scalability of an app by reducing the work required to generate content. Caching works best with data that changes infrequently and is expensive to generate. Caching makes a copy of data that can be returned much faster than from the source. Apps should be written and tested to never depend on cached data.

ASP.NET Core supports several different caches. The simplest cache is based on the IMemoryCache interface. IMemoryCache represents a cache stored in the memory of the web server. Apps running on a server farm (multiple servers) should ensure sessions are sticky when using the in-memory cache. Sticky sessions ensure that requests from a client all go to the same server. For example, an Azure web app uses Microsoft Application Request Routing (ARR) to route all requests to the same server.

Nonsticky sessions in a web farm require a distributed cache to avoid cache consistency problems. For some apps, a distributed cache can support higher scale-out than an in-memory cache. Using a distributed cache offloads the cache memory to an external process.

The in-memory cache can store any object. The distributed cache interface is limited to byte[]. The in-memory and distributed cache store cache items as key-value pairs.

Use System.Runtime.Caching/MemoryCache

System.Runtime.Caching/MemoryCache (NuGet package) can be used with:

  • .NET Standard 2.0 or later
  • Any .NET implementation that targets .NET Standard 2.0 or later (such as ASP.NET Core 3.1 or later)
  • .NET Framework 4.5 or later

Microsoft.Extensions.Caching.Memory/IMemoryCache (described in this article) is recommended over System.Runtime.Caching/MemoryCache because it offers better integration with ASP.NET Core. For example, IMemoryCache works natively with ASP.NET Core dependency injection.

Use System.Runtime.Caching/MemoryCache as a compatibility bridge when porting code from ASP.NET 4.x to ASP.NET Core.

Review guidelines for in-memory caching

The following guidelines apply to in-memory caching:

  • Code should always have a fallback option to fetch data and not depend on the availability of a cached value.

  • The cache uses memory, which is a scarce resource. Limit cache growth:

    • Don't insert external input into the cache. As an example, using arbitrary user-provided input as a cache key isn't recommended because the input might consume an unpredictable amount of memory.

    • Use expirations to limit cache growth.

    • Limit cache size with SetSize, Size, and SizeLimit. The ASP.NET Core runtime doesn't limit cache size based on memory pressure. The developer is responsible for limiting the cache size.

Create an instance of IMemoryCache

In-memory caching is a service that an app references by using dependency injection.

Warning

If the same cache is used by multiple frameworks or libraries, it's a shared cache. If you use a shared memory cache from dependency injection and also use SetSize, Size, and SizeLimit to limit the cache size, the app can fail.

When a size limit is set on a cache, all entries must specify a size when they're added. This approach can lead to issues because developers might not have full control over what uses the shared cache.

To limit the cache size with the SetSize method, the Size property, or the SizeLimit property, create a cache singleton for caching. For more information and an example, see Limit cache size with SetSize, Size, and SizeLimit.

Request the IMemoryCache instance in the constructor:

public class IndexModel : PageModel
{
    private readonly IMemoryCache _memoryCache;

    public IndexModel(IMemoryCache memoryCache) =>
        _memoryCache = memoryCache;

    // ...

The following code uses the TryGetValue method to check if a time is in the cache. If a time isn't cached, a new entry is created and added to the cache with the Set method:

public void OnGet()
{
    CurrentDateTime = DateTime.Now;

    if (!_memoryCache.TryGetValue(CacheKeys.Entry, out DateTime cacheValue))
    {
        cacheValue = CurrentDateTime;

        var cacheEntryOptions = new MemoryCacheEntryOptions()
            .SetSlidingExpiration(TimeSpan.FromSeconds(3));

        _memoryCache.Set(CacheKeys.Entry, cacheValue, cacheEntryOptions);
    }

    CacheCurrentDateTime = cacheValue;
}

In the preceding code, the cache entry is configured with a sliding expiration of 3 seconds. If the cache entry isn't accessed for more than 3 seconds, the entry is evicted from the cache. Each time the cache entry is accessed, it remains in the cache for a further 3 seconds. The CacheKeys class is part of the download sample.

The current time and the cached time are displayed:

<ul>
    <li>Current Time: @Model.CurrentDateTime</li>
    <li>Cached Time: @Model.CacheCurrentDateTime</li>
</ul>

The following code uses the Set extension method to cache data for a relative time without MemoryCacheEntryOptions:

_memoryCache.Set(CacheKeys.Entry, DateTime.Now, TimeSpan.FromDays(1));

In the preceding code, the cache entry is configured with a relative expiration of one day. The cache entry is evicted from the cache after one day, even if the entry is accessed during the timeout period.

The following code uses the GetOrCreate and GetOrCreateAsync methods to cache data.

public void OnGetCacheGetOrCreate()
{
    var cachedValue = _memoryCache.GetOrCreate(
        CacheKeys.Entry,
        cacheEntry =>
        {
            cacheEntry.SlidingExpiration = TimeSpan.FromSeconds(3);
            return DateTime.Now;
        });

    // ...
}

public async Task OnGetCacheGetOrCreateAsync()
{
    var cachedValue = await _memoryCache.GetOrCreateAsync(
        CacheKeys.Entry,
        cacheEntry =>
        {
            cacheEntry.SlidingExpiration = TimeSpan.FromSeconds(3);
            return Task.FromResult(DateTime.Now);
        });

    // ...
}

The following code calls the Get method to fetch the cached time:

var cacheEntry = _memoryCache.Get<DateTime?>(CacheKeys.Entry);

The following code gets or creates a cached item with absolute expiration:

var cachedValue = _memoryCache.GetOrCreate(
    CacheKeys.Entry,
    cacheEntry =>
    {
        cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(20);
        return DateTime.Now;
    });

A cached item set with only a sliding expiration is at risk of never expiring. If the cached item is repeatedly accessed within the sliding expiration interval, the item never expires. Combining a sliding expiration with an absolute expiration guarantees the item expires. The absolute expiration sets an upper bound on how long the item can be cached. It still allows the item to expire earlier, if the item isn't requested within the sliding expiration interval. If either the sliding expiration interval or the absolute expiration time passes, the item is evicted from the cache.

The following code gets or creates a cached item with both sliding and absolute expiration:

var cachedValue = _memoryCache.GetOrCreate(
    CacheKeys.CallbackEntry,
    cacheEntry =>
    {
        cacheEntry.SlidingExpiration = TimeSpan.FromSeconds(3);
        cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(20);
        return DateTime.Now;
    });

The preceding code guarantees the data isn't cached longer than the absolute time.

GetOrCreate, GetOrCreateAsync, and Get are extension methods in the CacheExtensions class. These methods extend the capability of IMemoryCache.

Create MemoryCacheEntryOptions for an entry

The following example demonstrates how to create MemoryCacheEntryOptions for an entry. The code completes the following tasks:

public void OnGetCacheRegisterPostEvictionCallback()
{
    var memoryCacheEntryOptions = new MemoryCacheEntryOptions()
        .SetPriority(CacheItemPriority.NeverRemove)
        .RegisterPostEvictionCallback(PostEvictionCallback, _memoryCache);

    _memoryCache.Set(CacheKeys.CallbackEntry, DateTime.Now, memoryCacheEntryOptions);
}

private static void PostEvictionCallback(
    object cacheKey, object? cacheValue, EvictionReason evictionReason, object? state)
{
    if (state is IMemoryCache memoryCache)
    {
        memoryCache.Set(
            CacheKeys.CallbackMessage,
            $"Entry {cacheKey} was evicted: {evictionReason}.");
    }
}

Limit cache size with SetSize, Size, and SizeLimit

A MemoryCache instance can optionally specify and enforce a size limit. The cache size limit doesn't have a defined unit of measure because the cache has no mechanism to measure the size of entries. If the cache size limit is set, all entries must specify size. The ASP.NET Core runtime doesn't limit cache size based on memory pressure. It's up to the developer to limit cache size. The size specified is in units the developer chooses.

For example:

  • If the web app primarily caches strings, each cache entry size might be the string length.
  • The app can specify the size of all entries as 1, and the size limit is the count of entries.

If the SizeLimit property isn't set, the cache grows without bound. The ASP.NET Core runtime doesn't trim the cache when system memory is low. Apps must be architected to:

  • Limit cache growth.
  • Call the Compact or Remove method when available memory is limited.

The following code creates a unitless fixed-size MemoryCache instance that's accessible by dependency injection:

public class MyMemoryCache
{
    public MemoryCache Cache { get; } = new MemoryCache(
        new MemoryCacheOptions
        {
            SizeLimit = 1024
        });
}

The SizeLimit property doesn't have units. Cached entries must specify size in whatever units they consider most appropriate when the cache size limit is set. All users of a cache instance should use the same unit system. An entry isn't cached if the sum of the cached entry sizes exceeds the value specified by SizeLimit. If no cache size limit is set, the cache size set on the entry is ignored.

The following code registers the MyMemoryCache instance with the dependency injection container:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddSingleton<MyMemoryCache>();

MyMemoryCache is created as an independent memory cache for components that are aware of this size-limited cache and know how to set cache entry size appropriately.

The size of the cache entry can be set by using the SetSize extension method or the Size property:

if (!_myMemoryCache.Cache.TryGetValue(CacheKeys.Entry, out DateTime cacheValue))
{
    var cacheEntryOptions = new MemoryCacheEntryOptions()
        .SetSize(1);

    // cacheEntryOptions.Size = 1;

    _myMemoryCache.Cache.Set(CacheKeys.Entry, cacheValue, cacheEntryOptions);
}

In the preceding code, the two highlighted lines achieve the same result of setting the size of the cache entry. The SetSize method is provided for convenience when chaining calls onto new MemoryCacheOptions().

Remove cache items with MemoryCache.Compact

The MemoryCache.Compact method attempts to remove the specified percentage of the cache in the following order:

  • All expired items
  • Items by priority, where lowest priority items are removed first
  • Least recently used objects
  • Items with the earliest absolute expiration
  • Items with the earliest sliding expiration

Pinned items with priority NeverRemove are never removed. The following code removes a cache item and calls the Compact method to remove 25% of cached entries:

_myMemoryCache.Cache.Remove(CacheKeys.Entry);
_myMemoryCache.Cache.Compact(.25);

For more information, see the Compact source on GitHub.

Evict a cache entry with expired dependencies

To enable inheritance of expiration tokens and time-based expiration settings between linked cache entries, set TrackLinkedCacheEntries to true when registering the cache in Program.cs. This option is false by default starting in .NET 7:

builder.Services.AddMemoryCache(options =>
{
    options.TrackLinkedCacheEntries = true;
});

The following sample shows how to expire a cache entry if a dependent entry expires. A CancellationChangeToken is added to the cached item. When the Cancel method is called on the CancellationTokenSource object, both cache entries are evicted:

public void OnGetCacheCreateDependent()
{
    var cancellationTokenSource = new CancellationTokenSource();

    _memoryCache.Set(
        CacheKeys.DependentCancellationTokenSource,
        cancellationTokenSource);

    using var parentCacheEntry = _memoryCache.CreateEntry(CacheKeys.Parent);

    parentCacheEntry.Value = DateTime.Now;

    _memoryCache.Set(
        CacheKeys.Child,
        DateTime.Now,
        new CancellationChangeToken(cancellationTokenSource.Token));
}

public void OnGetCacheRemoveDependent()
{
    var cancellationTokenSource = _memoryCache.Get<CancellationTokenSource>(
        CacheKeys.DependentCancellationTokenSource);

    cancellationTokenSource?.Cancel();
}

When you use a CancellationTokenSource object, you can evict multiple cache entries as a group. With linked-entry tracking enabled, the parent entry inherits the expiration token from the child entry created within its using scope.

Review notes about in-memory caching

The following notes apply to in-memory caching:

  • Expiration doesn't happen in the background.

    There's no timer that actively scans the cache for expired items. Any activity on the cache (via Get, TryGetValue, Set, or Remove) can trigger a background scan for expired items. A timer set on the CancellationTokenSource object (by using the CancelAfter method) also removes the entry and triggers a scan for expired items.

    The following example uses the CancellationTokenSource(TimeSpan) overloaded constructor for the registered token. When this token fires, it removes the entry immediately and fires the eviction callbacks:

    if (!_memoryCache.TryGetValue(CacheKeys.Entry, out DateTime cacheValue))
    {
        cacheValue = DateTime.Now;
    
        var cancellationTokenSource = new CancellationTokenSource(
            TimeSpan.FromSeconds(10));
    
        var cacheEntryOptions = new MemoryCacheEntryOptions()
            .AddExpirationToken(
                new CancellationChangeToken(cancellationTokenSource.Token))
            .RegisterPostEvictionCallback((key, value, reason, state) =>
            {
                (state as CancellationTokenSource)?.Dispose();
            }, cancellationTokenSource);
    
        _memoryCache.Set(CacheKeys.Entry, cacheValue, cacheEntryOptions);
    }
    
  • When you use a callback to repopulate a cache item:

    • Multiple requests might discover the cached key value is empty because the callback isn't finished.
    • This approach can result in several threads repopulating the cached item.
  • When linked-entry tracking is enabled and one cache entry (the parent) creates another entry (the child), the parent inherits the child's expiration tokens and time-based expiration settings. Manually removing or updating the child doesn't expire the parent.

  • Use the PostEvictionCallbacks property to specify which callbacks should fire after a cache entry is evicted from the cache.

  • For most apps, IMemoryCache is enabled. For example, calling AddMvc, AddControllersWithViews, AddRazorPages, AddMvcCore().AddRazorViewEngine, and many other Add{Service} methods in the Program.cs file enables IMemoryCache.

    For apps that don't call one of the mentioned Add{Service} methods, you might need to call the AddMemoryCache method in the Program.cs file.

  • MemoryCache is thread-safe and can be used concurrently across multiple threads.

  • Accessing the Count property is computationally expensive under high concurrency because it requires aggregating items across internal partitions. Avoid accessing Count frequently in performance-sensitive request paths.

  • GetOrCreate and GetOrCreateAsync aren't atomic. When several requests miss the same key at the same time, each request runs the factory, and the last value written is the one that stays in the cache. For an expensive data source, this can cause a burst of duplicate calls. To run the factory only once per key, use HybridCache, which provides stampede protection.

  • The cache stores references to objects, not copies. If code changes an object after reading it from the cache, every caller that reads that entry sees the change. Cache immutable objects, or copy an object before changing it.

Use a background cache update

Use a background service such as the IHostedService interface to update the cache. The background service can recompute the entries and assign them to the cache only after they're ready.