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.
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:
- look up the cache key
- if missing, run the async operation
- store the resulting value
- return the value
IMemoryCache already gives you a good entry point for that with GetOrCreateAsync:
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:
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 typeT. - '
GetOrCreateAsyncis 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
- Pros and cons of multi-leader vs leaderless replication in databases?
- proxy for distributed file share system in window
- pub/sub middleware with persistent storage
- Pushing avro file to Kafka
- Proper way to implement a never ending task. Timers vs Task
- Pros and cons of async/await
- Proper way to implement ICloneable
- Property cannot be declared public because its type uses an internal type

System Design Fundamentals
Build a strong foundation in designing scalable, reliable distributed systems.
View the courseTrack 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.