Master Cache in C#
What a cache is, and when should you use it? IMemoryCache, IDistributedCache, and HybridCache. Redis. Cache invalidation. Metrics and statistics. And finally, different patterns of using cache.
Enjoy 😉
Overview
Let’s say we have a simple application that sends HTTP requests to the external application.
The naive implementation may have the next look:
app.MapGet("/get-weather-forecast", () =>
{
return _weatherClientApi.GetWeather();
});There are 2 factors to consider:
- usually, IO operations are slow, causing slowness in our application
- the response of this action does not change frequently
To optimize the code, you can store the result of the request in a variable:
var weatherResult = null;
app.MapGet("/get-weather-forecast", () =>
{
if (weatherResult is not null)
{
return weatherResult;
}
weatherResult = _weatherClientApi.GetWeather();
return weatherResult;
});This way you don’t execute the request, which means faster response and less load on the external server. Congratulations, we just invented the cache 😅.
Cache is a storage for a frequently accessed data to speed up data retrieval and reduce the load on the primary data source
Caching is one of the easiest ways to increase system performance. Databases can be slow, external systems can be slow, algorithms can be slow, and so on. All those things can be cached 😁.
To Cache or Not To Cache 🤔
The cache is not a silver bullet, you should understand that there are cases when the cache is suitable:
- static data: data does not change often
- frequent reads: data is accessed repeatedly
- expensive data generation: generating or retrieving the data is resource-intensive
However, adding a cache can also lead to challenges:
- highly dynamic data: data that changes too frequently, making caching inefficient
- staleness intolerance: the application requires real-time accuracy
- low read frequency: storing rarely accessed data increases application memory usage
- complex dependency management: data with intricate relationships can complicate cache invalidation
Cache In C#
Sure, you can create temporary variables like we did above, but caching is such a popular concept, that C# provides us with multiple implementations
IMemoryCacheIDistributedCacheHybridCache
Let’s quickly review them.
IMemoryCache
Behold IMemoryCache. This is basically IDictionary<K, V>. It stores the cache entries in memory and is flashing fast.
The IMemoryCache is easy to use. It just requires 2 steps:
- register the memory cache in the DI using
.AddMemoryCache() - inject and use the
IMemoryCacheservice
using Microsoft.Extensions.Caching.Memory;
var builder = WebApplication.CreateBuilder(args);
// 👇
builder.Services.AddMemoryCache();
var app = builder.Build();
app.MapGet("/get-weather-forecast", (IMemoryCache memoryCache) =>
{
// 👇
return memoryCache.GetOrCreate(key: "weather", (cacheEntry) =>
{
return _weatherClientApi.GetWeather();
});
});
app.Run();Its core features:
- performance. stores data in the application’s memory, making it increasingly fast
- thread-safe. designed for concurrent access in multi-threaded environments, meaning stampede protection
- atomic operations. it has a single, atomic method to retrieve and set a value at the same time (
.GetOrCreate())
However, there are always drawbacks:
- scalability. data cached in memory is only available for a single application instance
- reusability. the cache is not shared across servers in distributed system
- memory load. stored data increases application memory usage
- process-bound. cache is lost if the application restarts
IDistributedCache
IDistributedCache stores and loads data from an external storage.
The benefits of having a cache in the database are the following:
- centralized cache storage. different applications have access to the same cache. The cache is shared across servers in distributed system
- scalability. when the application is scaled horizontally, all instances share the same cache
- memory load. cache does not affect application memory
- durability. unlike in-memory caches, it can persist data across application restarts
Remember the drawbacks:
- performance. getting a cache requires a request to the external storage, which is slow
- thread-unsafe. you need to implement locking for concurrent cache access
- serialization overhead. requires serialization and deserialization of objects, which can impact performance
.Net has support for the next distributed cache providers:
- in memory
- SQL
- Redis
Let’s review them.
- Memory
This is not a distributed cache 😅. There is no database. As before, the cache is stored in the memory of the application itself and doesn’t expand across multiple machines.
This implementation just registers IDistributedCache interface. It is useful when you want to provide a dependency for third-party libraries but does not need an actual distributed cache.
To use IDistributedCache you need:
- register the distributed cache in the DI (
.AddDistributedMemoryCache()) - inject and use the
IDistributedCacheservice
using Microsoft.Extensions.Caching.Distributed;
var builder = WebApplication.CreateBuilder(args);
// 👇
builder.Services.AddDistributedMemoryCache();
var app = builder.Build();
app.MapGet("/get-weather-forecast", (IDistributedCache distributedCache) =>
{
var cacheKey = "weather";
var weatherCache = distributedCache.GetString(cacheKey);
if (!string.IsNullOrEmpty(weatherCache))
{
return JsonConvert.DeserializeObject<WeatherResponse>(weatherCache);
}
var weather = _weatherClientApi.GetWeather();
distributedCache.SetStringAsync(cacheKey, JsonConvert.SerializeObject(weatherCache));
return weather;
});
app.Run();Notice:
- there is no atomic
.GetOrCreate()method. You need to use a combination of.GetCache(),.SetCache()methods. Not only it is not convenient, but also you have to worry about concurrent thread access - you need to serialize a cache to
byte[]orstring. In the example above JSON serialization is used, but you can also rely on binary serialization, Protobuf, and so on
- SQL Server
You can store the cache in the SQL database.
First, make sure the next table is present:
CREATE TABLE [dbo].[CacheTable]
(
[Id] nvarchar(449) NOT NULL PRIMARY KEY,
[Value] varbinary(MAX) NOT NULL,
[ExpiresAtTime] datetimeoffset NOT NULL,
[SlidingExpirationInSeconds] bigint NULL,
[AbsoluteExpiration] datetimeoffset NULL
);Add the corresponding NuGet:
dotnet add package Microsoft.Extensions.Caching.SqlServerFinally, change the way you register IDistributedCache in the DI:
. . .
builder.Services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = @"Server=.;Database=YOUR_DB_NAME;TrustServerCertificate=True;Trusted_Connection=SSPI;Encrypt=false;";
options.SchemaName = "dbo";
options.TableName = "CacheTable";
});
. . .The rest of the code remains the same.
I assume you know what SQL is and how to work with it, so we can move to the next section 🙃.
You can also use other SQL providers like PostgreSQL with (Microsoft.Extensions.Caching.Postgres).
- Redis
The cache can also be stored in Redis.
Redis (Remote Dictionary Server) — is a NoSQL, key-value based database with minimal latency
You can use Redis as a primary storage, it also supports message broker capabilities, but low latency makes it particularly suitable as a cache.
Redis is fast as hell because it holds all the data in the RAM. However, it also means that you will lose everything if the storage is down.
First, you need to install Redis. If you are on Windows, you can use this installer or via Docker.
Once installed and running, you can execute some CLI commands against it. I find this one particularly useful 😁:
# deletes all entries
redis-cli flushallThere are also multiple GUIs for Redis. I settle on Redis Commander. It is easy to install and use:
Now, that you know what Redis is and how to work with it, we can finally use it in our project.
Install the package:
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedisChange the DI configurations:
. . .
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
. . .And voila, we have Redis as a distributed cache 😁.
HybridCache
HybridCache combines the best of both in-memory and out-of-process caching.
It uses IMemoryCache as primary cache storage. However, if the value is missing, it will be loaded and added from a secondary cache, which is IDistributedCache.
This means:
- high performance. each application loads data from its own, high-performant in-memory caching
- thread-safe. it is designed for concurrent access in multi-threaded environments, meaning stampede protection
- atomic operations. it has a single, atomic method to retrieve and set values at the same time (
.GetOrCreate()) - centralized cache storage. different applications have access to the same cache. The cache is shared across servers in distributed system
- scalability. when the application is scaled horizontally, all instances share the same cache
The usage is the same as previously. Install the NuGet:
dotnet add package Microsoft.Extensions.Caching.HybridRegister in the DI and play with HybridCache service:
var builder = WebApplication.CreateBuilder(args);
// 👇
builder.Services.AddHybridCache();
var app = builder.Build();
app.MapGet("/get-weather-forecast", (HybridCache hybridCache) =>
{
// 👇
return hybridCache.GetOrCreate(key: "weather", (cacheEntry) =>
{
return _weatherClientApi.GetWeather();
});
});
app.Run();Because IDistributedCache implementation is not registered, the HybridCache will only store entries in memory.
However, if registered like this, it will use both IMemoryCache and IDistributedCache.
. . .
builder.Services.AddHybridCache();
// 👇
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
. . .The serialization can be easily customized if you want to use more size concise formats compared to JSON:
. . .
builder
.Services
.AddHybridCache()
.AddSerializerFactory<GoogleProtobufSerializerFactory>();
. . .You can also decide for each cache entry whether it is stored, in memory, or in the external storage. This makes IMemoryCache and IDistributedCache obsolete.
app.MapGet("/get-weather-forecast", (HybridCache hybridCache) =>
{
return hybridCache.GetOrCreate(key: "weather", (cacheEntry) =>
{
cacheEntry.Flags = HybridCacheEntryFlags.DisableLocalCache; // 👈
// - or -
cacheEntry.Flags = HybridCacheEntryFlags.DisableDistributedCache; // 👈
return _weatherClientApi.GetWeather();
});
});The last thing I want to mention about the HybridCache is that you inject implementation directly instead of an interface. But don’t worry. It is an abstract one where all methods are marked as virtual, so it still can be easily mocked in the unit tests.
Cache Invalidation Methods
It is useful not only to add values to the cache but also to remove stale or outdated data. This way you ensure that only the most recent data is available for use.
A few cache invalidation strategies that will be discussed:
- manual invalidation
- time-based invalidation (TTL)
- event-based invalidation
- priority-based invalidation
- least recently used invalidation (LRU)
- most recently used invalidation (MRU)
- last in, first out invalidation (LIFO)
- first in, first out invalidation (FIFO)
We will quickly review all of them, but from my experience, only the first three are important 😅.
Manual Invalidation
You can explicitly remove the cache by its key.
app.MapGet("/get-weather-forecast", (MemoryCache cache) =>
{
return cache.GetOrCreate(key: "weather", (cacheEntry) =>
{
return _weatherClientApi.GetWeather();
});
});
app.MapDelete("/clear-weather-cache", (MemoryCache cache) =>
{
cache.Remove(key: "weather"); // 👈
});The cache can also be removed by someone with high privileges, like an administrator.
Time-Based Invalidation (TTL)
The cache can be automatically removed after a certain time has passed.
app.MapGet("/get-weather-forecast", (MemoryCache cache) =>
{
return cache.GetOrCreate(key: "weather", (cacheEntry) =>
{
cacheEntry.SetAbsoluteExpiration(TimeSpan.FromHours(4)); // 👈
cacheEntry.SetSlidingExpiration(TimeSpan.FromHours(1)); // 👈
// - or -
cacheEntry.AbsoluteExpiration = DateTimeOffset.Now.AddHours(4) // 👈
cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(4); // 👈
cacheEntry.SlidingExpiration = TimeSpan.FromHours(1); // 👈
return _weatherClientApi.GetWeather();
});
});- Absolute Expiration ensures an item is removed after a set duration
- Sliding Expiration sets the expiration time of the cache. Each time the cache is accessed, the expiration time resets
You can use them separately or together. Notice, that if both are set, the item will expire when either one of the expiration periods is exceeded.
Event-Based Invalidation
You can invalidate the cache based on specific events that occur in the system.
Get iamprovidence’s stories in your inbox
Join Medium for free to get updates from this writer.
Usually, this type of invalidation requires custom logic, but I want to share with you this feature of IMemoryCache. You can invalidate the cache with a token that can be triggered by an event:
// create a CancellationTokenSource to control the change token
var cts = new CancellationTokenSource();
app.MapGet("/get-weather-forecast", (IMemoryCache cache) =>
{
return cache.GetOrCreate(key: "weather", (cacheEntry) =>
{
// 👇
cacheEntry.AddExpirationToken(new CancellationChangeToken(cts.Token));
return _weatherClientApi.GetWeather();
});
});
app.MapPost("/change-configuration", (MemoryCache cache) =>
{
cts.Cancel(); // 👈
});Priority-Based Invalidation
In IMemoryCache, you can set priority for cache entries.
builder.Services.AddMemoryCache(x => x.SizeLimit = 19_992_702; // 👈
app.MapGet("/get-weather-forecast", (IMemoryCache cache) =>
{
return cache.GetOrCreate(key: "weather", (cacheEntry) =>
{
cacheEntry.Priority = CacheItemPriority.Normal; // 👈
return _weatherClientApi.GetWeather();
});
});If cache clean-up is triggered by memory pressure items will be removed by the next priorities:
- Low. items with this priority are the first to be evicted
- Normal (default). the cache entries are removed if there are no other low-priority cache entries
- High. the entries are removed at the last
- NeverRemove. items are not automatically evicted due to memory pressure but can still be removed manually or due to expiration policies
Least Recently Used Invalidation (LRU)
This strategy implies that the least recently used item in the cache is invalidated when the cache reaches its capacity limit.
This ensures that the most frequently used data remains in the cache and reduces the risk of storing stale data.
Most Recently Used Invalidation (MRU)
As opposed to the previous, under pressure, the most recently accessed cache is removed.
This is useful when a cache being looked up, will not be looked up again in the relatively near future.
For example, in a taxi system, if a user has just viewed the number of a cab he is waiting for, it is unlikely the user will check it again.
Last In, First Out Invalidation (LIFO)
This strategy removes the most recently added item from the cache first, assuming newer items are less likely to be reused soon.
First In, First Out Invalidation (FIFO)
This strategy removes the oldest item in the cache first, regardless of usage, based on the assumption that older data is less relevant.
Cache Metrics
You can improve your cache usage based on improving different metrics.
IMemoryCache can return some statistics values:
interface IMemoryCache : IDisposable
{
. . .
MemoryCacheStatistics? GetCurrentStatistics();
. . .
}
class MemoryCacheStatistics
{
public long CurrentEntryCount { get; init; }
public long? CurrentEstimatedSize { get; init; }
public long TotalMisses { get; init; }
public long TotalHits { get; init; }
}But for the distributed one, you should monitor logs and collect telemetry from tools like Application Insights.
Proper monitoring and optimization of these metrics can significantly enhance the performance and reliability of your cache system.
Latency
Latency refers to the time (milliseconds) it takes to access data.
Lower latency means that data is being served faster, improving overall performance.
Ensure cache latency is lower than latency for accessing data from the original source.
Throughput
Throughput is measured as the number of operations per second (ops/sec) your system can handle.
A higher throughput is better. It means the system can handle more concurrent requests.
Throughput can be improved when data is loaded from the cache, and not from a slower storage. Additionally, you can scale horizontally or vertically.
Hit Ratio (Cache Hit Rate)
The percentage of requests served directly from the cache.
A higher cache hit ratio indicates that more data is retrieved from the cache, minimizing external storage access and enhancing performance.
Improve by tuning cache size, eviction policies, and preloading critical data.
Miss Ratio (Cache Miss Rate)
The percentage of requests that must fetch data from the original data source due to cache misses.
A high miss rate often means that more data is being fetched from external storage. It leads to higher latency and increased backend load.
Eviction Rate
Rate at which cached items are removed to free space for new entries.
A high eviction rate may indicate insufficient cache capacity or inefficient data usage patterns.
Consider changing eviction policy (e.g., LRU, MRU, FIFO, TTL).
Time-to-Live (TTL) Efficiency
Measures the effectiveness of TTL in balancing data freshness with cache hits:
- short TTL: ensures data freshness but increases backend load
- long TTL: improves hit ratio but risks staleness
Fine-tune TTL based on access patterns and data change frequency.
Cache Size
The amount of memory or storage allocated for the cache.
- large size: can result in a higher hit rate but increases the backend load
- small size: can decrease hit rate saving up server resources
Optimize cache size and choose the appropriate one.
Backpressure
The impact of cache misses on the underlying data source due to increased load.
Usually indicated by spikes in the database or backend service usage.
Improve hit ratio or implement rate-limiting to reduce backend strain.
Caching Strategies
As always, developers steal each code and call it patterns 😅. Same with cache. There are popular patterns/strategies/approaches of how to deal with a cache.
When you read from the cache, you have the following options:
- Cache-Aside (Lazy Loading)
- Cache-Through
Next strategies are used when you write to the cache:
- Write-Through
- Write-Behind (Write-Back)
- Write-Around
We will discuss them in more detail below.
Cache-Aside (Lazy Loading)
This is one of the most intuitive and known patterns.
Overview:
1. the application tries to read data from the cache
2. if data is missing in the cache, the application reads it from the database
3. the data is written to the cache before return
You have already seen an example of Cache-Aside multiple times in this article:
memoryCache.GetOrCreate(key: "data", (cacheEntry) =>
{
return _db.GetData();
});pros
- the application continues working in case of a cache miss
- the application controls cache behavior, allowing customization for specific use cases
- it avoids storing unused data in the cache, reducing memory overhead
cons
- a cache miss requires a database lookup, which increases the response time for that operation
- data can become stale if the database is updated directly without reflecting the changes in the cache
Read-Through
In this pattern, the application only gets data from the cache.
The cache is usually a separate class, that remembers what data was requested and loads it in the background.
Overview:
1. the application reads data only from the cache
2. if data is missing in the cache, the cache gets the data from the database and updates itself
pros
- the application does not need to manage cache miss handling; the cache handles it automatically
- frequently accessed data is cached, reducing database load and response times for reads.
cons
- on a cache miss, data retrieval involves both the cache and the database, potentially increasing latency
Write-Through
The Write-Through Cache Pattern ensures that any write operation to the cache is immediately applied to the underlying database.
The cache and the database have the same values making the cache consistent.
Overview:
1. the application writes data directly to the cache
2. the cache writes data to the database
pros
- ensures the cache and database are always synchronized, reducing the risk of stale or inconsistent data
- since the cache holds up-to-date data, the application can rely on it for reads without additional database queries
- frequently accessed data is already in the cache, reducing database load and response time
cons
- each write operation involves both the cache and the database, increasing the time required for the operation
- high write traffic can overwhelm the cache-database synchronization, especially in distributed systems
- most of the data present in the cache might never get requested
Write-Behind (Write-Back)
Write-behind is similar to write-through.
Instead of immediately synchronizing with the database, writes are batched and deferred to improve performance.
Overview:
1. the application writes data directly to the cache
2. the cache acknowledges the write operation. Later, the cache asynchronously writes the data to the database
pros
- writes are acknowledged quickly because the cache does not block while updating the database
- batching write operations minimizes the frequency of database writes, improving efficiency under heavy write loads
- data is immediately available in the cache for reads
cons
- if the cache fails before flushing writes to the database, data may be lost
- since writes to the database are delayed, the database might temporarily hold outdated information
Write-Around
The Write-Around avoids writing data to the cache during write operations.
Instead, data is written directly to the database, and the cache is only updated when the data is read later.
Overview:
1. the application writes data only to the database
The cache is populated during read operation by Cache-Aside or Read-Through strategies.
pros
- ensures the cache stores only frequently accessed data, avoiding unnecessary caching of infrequently accessed writes
- eliminates the need to synchronize writes between the cache and database, reducing complexity
- reduces the number of write operations to the cache, which can benefit systems with high write traffic
cons
- the first read of new data incurs additional latency as it must be fetched from the database and then added to the cache
Conclusion
As you can see cache is not a golden bullet 🤷♂️. It may help but it can also cause additional back pain. Find places where it is useful, but consider the advantages and drawbacks of each approach.
In this article, a lot of topics were covered. It does not mean you should know everything by heart 🙃. Highlight for yourself the next key points, as they will be most relevant to your daily work:
- make a conscious choice between memory and distributed cache
- have a basic understanding of what Redis is and how to work with
- learn how to remove cache by key
- see the difference between
AbsoluteExpirationandSlidingExpiration - know what the Cache-Aside pattern is
💬 Let me know how often QA returns you a user story because they forget to clear the cache 😅
👏 Give this article a clap or a few if you like it
☕️ You can support me with a link below
✅ Don’t forget to follow to receive more insight
😁 Cache this article in your ‘Reading list’ for faster access
😉 See you around









