IMemoryCache
caching
asynchronous programming
Task\`\`\`\`\`<T>\`\`\`\`\`
.NET

Proper way to cache results TaskT with IMemoryCache

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

Introduction

When an expensive operation is asynchronous, the question is not only "should I cache it?" but also "what exactly should I put in the cache?" With IMemoryCache, the safest default is to cache the resolved value of type T, not an arbitrary Task<T> object that may still be running or may already be faulted.

Cache the Result, Not an Accidental Execution State

For ordinary application caching, the clean pattern is:

  1. look up the cache key
  2. if missing, run the async operation
  3. store the resulting value
  4. return the value

IMemoryCache already gives you a good entry point for that with GetOrCreateAsync:

csharp
1using Microsoft.Extensions.Caching.Memory;
2
3public sealed class ProductService
4{
5    private readonly IMemoryCache _cache;
6    private readonly HttpClient _httpClient;
7
8    public ProductService(IMemoryCache cache, HttpClient httpClient)
9    {
10        _cache = cache;
11        _httpClient = httpClient;
12    }
13
14    public Task<string> GetProductAsync(int id)
15    {
16        return _cache.GetOrCreateAsync($"product:{id}", async entry =>
17        {
18            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
19            return await _httpClient.GetStringAsync(
20                $"https://example.test/products/{id}");
21        })!;
22    }
23}

This caches the final string value. It does not require you to manually cache a task instance.

Why Caching Task<T> Directly Is Usually the Wrong Default

Storing a raw Task<T> in the cache can work, but it couples your cache entry to execution state. That leads to several problems:

  • a faulted task can stay cached and replay the same exception forever
  • a canceled task can remain cached even though the data was never loaded
  • debugging becomes harder because the cache no longer holds plain data

In other words, you are no longer caching "the answer." You are caching "the current state of one asynchronous attempt to get the answer."

That is sometimes useful, but it should be intentional.

Concurrency and Cache Stampede

One subtle issue matters a lot in real systems: IMemoryCache is thread-safe, but expensive misses can still trigger multiple concurrent factory executions for the same key.

So this is not automatically guaranteed:

  • ten requests arrive for one missing key
  • only one backend request runs
  • all callers await the same result

If avoiding duplicate work matters, add per-key locking or a deliberate in-flight deduplication strategy.

Here is a simple SemaphoreSlim example:

csharp
1using Microsoft.Extensions.Caching.Memory;
2using System.Collections.Concurrent;
3
4public sealed class UserProfileCache
5{
6    private readonly IMemoryCache _cache;
7    private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
8
9    public UserProfileCache(IMemoryCache cache)
10    {
11        _cache = cache;
12    }
13
14    public async Task<string> GetAsync(string key, Func<Task<string>> factory)
15    {
16        if (_cache.TryGetValue(key, out string cached))
17            return cached;
18
19        var gate = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
20        await gate.WaitAsync();
21
22        try
23        {
24            if (_cache.TryGetValue(key, out cached))
25                return cached;
26
27            var value = await factory();
28            _cache.Set(key, value, TimeSpan.FromMinutes(10));
29            return value;
30        }
31        finally
32        {
33            gate.Release();
34        }
35    }
36}

This ensures only one loader fills the cache for a missing key at a time.

When Caching a Task Is Reasonable

There is one legitimate reason to cache Task<T>-like objects: deduplicating in-flight work. In that design, the task itself is acting as a shared promise for concurrent callers.

If you choose that route, use it deliberately through a wrapper such as Lazy<Task<T>>, and make sure faulted entries are removed so transient failures do not poison the cache.

That is very different from casually sticking whatever task you have into IMemoryCache.

Expiration and Failure Rules

Async caching still needs ordinary cache policy:

  • use absolute expiration when stale data must be bounded
  • use sliding expiration when active keys should stay hot
  • invalidate on writes when freshness matters

Also decide what to do with failures. A practical default is:

  • cache successful values
  • do not cache exceptions
  • optionally cache "not found" results for a short time if they are expensive to compute

These policies are easier to reason about when the cache stores values rather than task objects.

Common Pitfalls

The biggest mistake is assuming GetOrCreateAsync automatically gives full stampede protection. It simplifies async population, but it is not a distributed locking system.

Another mistake is caching faulted or canceled tasks and then replaying the same failure to every future caller.

People also forget that IMemoryCache is process-local. In a multi-instance deployment, each server has its own memory cache unless you move to a distributed or hybrid cache.

Finally, do not skip expiration settings. A fast cache that never refreshes can become a correctness bug instead of a performance feature.

Summary

  • With IMemoryCache, the safest default is to cache the resolved value of type T.
  • 'GetOrCreateAsync is the simplest way to populate cache entries from async work.'
  • Thread-safe access does not automatically prevent duplicate concurrent cache fills.
  • Cache Task<T> only when you intentionally want in-flight deduplication behavior.
  • Define expiration and failure policies explicitly so async caching stays predictable.

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

All Rights Reserved.