Updated on
To cache data in Redis from C#, we register AddStackExchangeRedisCache() in Program.cs, inject IDistributedCache, and read through the cache: ask Redis first, fall back to the database on a miss, and write the result back with an expiration.
Redis is an in-memory key-value store that runs as a separate process, so the cache survives an application restart and is shared across every instance behind a load balancer. That is the difference between it and IMemoryCache, and it is the whole reason to take on a second server.
We’ll be using Docker to install and set up the Redis server in this article. So, a basic knowledge of Docker is a prerequisite.
VIDEO: How to Improve Performance of Web APIs Using Redis Cache.
What Is Caching?
Caching is the process of storing data in temporary storage (called cache) to improve the speed of data access. We need to make sure that this storage is faster and more easily accessible than the original data source which can be a database or an API.
When an application requests data, we first check if the data is present in the cache. If so, we return the cached data. This process saves us a trip to the original data source which might be costly in terms of performance. Even if the data is not present in the cache on the first request, we can store it in the cache when retrieving it from the external data source, thus making the subsequent requests better in performance.
Also, we can add expiration time to the cache storage so that we can be sure about the consistency of the data stored in it.
Now that we understand what caching is and how it works, let’s take a look at Redis.
What Is Redis?
Redis (Remote Dictionary Server) is an in-memory key-value store that allows fast data look-up. We use it primarily as an application cache.
Since Redis 8, Redis Open Source ships under a tri-licence — the Redis Source Available License v2, the Server Side Public License v1, or the GNU Affero General Public License v3 — so it is open source only through the AGPLv3 arm. Releases up to and including 7.2 remain under the BSD 3-clause licence. The community fork Valkey stayed on BSD 3-clause and is actively maintained.
When our applications rely on external data sources such as a database or an API, the performance and the latency of the data access are limited by those resources. We have a bottleneck especially while scaling the application.
This is the problem Redis intends to solve. It combines the best of in-memory caching, to increase the performance of data access and reduce the latency, with distributed caching, to provide scalability and resilience through data replication.
How Do We Use Redis Cache in C#?
We use Redis from C# through the IDistributedCache interface, implemented by the Microsoft.Extensions.Caching.StackExchangeRedis package.
Registration is a single call in Program.cs. We give it a connection string pointing at the Redis server and, optionally, an InstanceName that prefixes every key, so two applications sharing one Redis database cannot collide.
From there the surface is small. GetString(key) returns the cached value or null, SetString(key, value, options) writes it with an expiration policy, and Remove(key) deletes it.
Redis stores bytes rather than objects, so we serialise to JSON on the way in and deserialise on the way out. Every read follows the same shape: ask the cache first, call the database only on a miss, then write what we got back so the next request does not have to.
For Redis features the cache abstraction does not expose (lists, sets, pub/sub), we use the StackExchange.Redis client directly through IConnectionMultiplexer.
That same abstraction is what every other provider implements as well, so the shape of this code is identical for distributed caching in ASP.NET Core backed by SQL Server or NCache. The rest of this article walks the Redis implementation end to end.
Redis Setup
To get started with Redis setup, we need to set up Docker on our machine.
The first step is to set up a Redis server using the docker command in the command prompt:
docker run --name redis-cache -p 90:6379 -d redis
Here, we create a new Docker container and run it. The --name redis-cache flag sets the name of this container to “redis-cache”.
We map the host machine’s port to the Redis container’s port with the -p 90:6379 flag. Here, “6379” is the default port for Redis that is mapped to port 90 of the host machine. We can select any available port here.
The flag -d ensures that the container runs in a detached mode i.e. in the background. Finally redis is the name of the Docker image used to create the container. If the Redis image is not already present, Docker will download it.
Running the docker ps -a command, we can see all the Docker containers:
C:\Windows\System32>docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 2884e447b239 redis "docker-entrypoint.s…" 25 hours ago Up 51 minutes 0.0.0.0:90->6379/tcp redis-cache
Now that we are done creating the Redis server, let’s execute it:
docker exec -it redis-cache sh
This command launches an interactive shell session with our container. Then, we can execute specific commands inside the Redis server.
That’s it. Now that the Redis server is up and running, let’s set up our application.
C# Application Setup
We will create a simple ASP.NET Core Web Application with all the default options enabled, so let’s use the Visual Studio Project wizard or the dotnet new webapp to do so.
Now, before we begin modifying our web application, let’s install a couple of NuGet packages we need to interact with Redis.
From the command line, we install Microsoft.Extensions.Caching.StackExchangeRedis:
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
This package allows us to use caching in the web application. Additionally, we need to install StackExchange.Redis:
dotnet add package StackExchange.Redis
This is a client library that allows us to interact with Redis.
Application Configuration
Now, let’s add Redis as our caching provider in our application. To do so, we need to modify the Main() method in the Program class:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("RedisConn");
options.InstanceName = "GamesCatalog_";
});
This adds our caching information to the dependency injection system of the application. Hence, we can inject the IDistributedCache interface into any of our services.
It is important to note that options.InstanceName is an optional property that allows us to prepend a custom instance name to all our cache keys. This is important when we have multiple applications using the same Redis database. It allows us to differentiate between the keys from different instances even if they have the same name.
In the options.Configuration property, we specify the connection string for the Redis server.
Let’s create a connection string “RedisConn” in the appSettings.json file:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"RedisConn": "localhost:90"
}
}
The port number we specify here (90) must match the port we mapped the Redis server to in the Docker setup.
Data Setup
In a real-world scenario, we’d be using a database such as SQL or any external API as our data source. We’ll keep it simple in our example and use static JSON data to act as our external data source.
However, let’s first create our model class Game:
public class Game
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Genre { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public int ReleaseYear { get; set; }
}
We’ll use this class to map the data from a JSON file GamesData.json:
[
{
"Id": 1,
"Title": "The Witcher 3: Wild Hunt",
"Genre": "Action RPG",
"Platform": "PlayStation 4",
"ReleaseYear": 2015
},
{
"Id": 2,
"Title": "Red Dead Redemption 2",
"Genre": "Action Adventure",
"Platform": "Xbox One",
"ReleaseYear": 2018
}
]
This file contains data that represents an array of Game objects. We need to make sure that the structure of the JSON data aligns with the properties of the Game class.
Now that we have our model class and the JSON data file, we can go ahead with mapping the data to the model class.
Let’s create a service class GamesService and add a method to retrieve all Game objects present in our data source:
public Game[] LoadGames()
{
using var streamReader = new StreamReader("Data/GamesData.json");
var gamesData = streamReader.ReadToEnd();
var games = JsonSerializer.Deserialize<Game[]>(gamesData);
return games;
}
Here, we read the content of the JSON file and then deserialize the JSON data into an array of Game objects. Then, we can use this array Game[] in the application.
Razor Page Setup
Now that we have our data, the next step is to create a user interface to present this data to the user.
Let’s modify the Index.cshtml razor page to show a button that when clicked, will populate the UI with game data.
The button click will send a POST request to our page model:
public class IndexModel : PageModel
{
private readonly GamesService _gamesService;
public Game[]? Games { get; set; }
public IndexModel(GamesService gamesService)
{
_gamesService = gamesService;
}
public void OnPostListGames()
{
Games = _gamesService.LoadGames();
}
}
We use the GamesService class to populate game data in the Games property of the Index.cshtml.cs razor page model. Now, we can use this property in the Index.cshtml razor page using Model.Games:
@if (Model.Games != null && Model.Games.Length > 0)
{
<table class="table table-striped table-bordered">
<thead class="thead-dark">
<tr>
<th>Title</th>
<th>Genre</th>
<th>Platform</th>
<th>Release Year</th>
</tr>
</thead>
<tbody>
@foreach (var game in Model.Games)
{
<tr>
<td>@game.Title</td>
<td>@game.Genre</td>
<td>@game.Platform</td>
<td>@game.ReleaseYear</td>
</tr>
}
</tbody>
</table>
}
Here, we create an HTML table with bootstrap CSS classes. We have table headers that represent every property from the Game class. Finally, we iterate over the Model.Games collection to create a new row for each game and store the appropriate information in the respective columns.
Implement Redis Caching in C#
The implementation is a thin service over IDistributedCache plus one decision at each call site: read from the cache, or read from the source and populate it.
We wrap the cache in a small class so the serialisation lives in one place. A generic GetCachedData<T>(key) fetches the string, returns default when the key is absent, and deserialises otherwise. A matching SetCachedData<T>(key, data, duration) serialises and writes with an expiration.
The call site then reads through the cache. It asks for the key, and when the result is null it calls the real data source, stores what came back, and returns it.
Two details decide whether this works in production. Cache keys must be unique per logical result: include every input that changes the answer, or two different queries will share one entry. And every write needs an expiration, because an entry with no policy stays until something explicitly removes it.
Caching Service
Let’s create a RedisCacheService class:
public class RedisCacheService
{
private readonly IDistributedCache? _cache;
public RedisCacheService(IDistributedCache cache)
{
_cache = cache;
}
}
Here, we inject the IDistributedCache interface using the dependency injection system of ASP.Net Core. Thus, we can integrate Redis as the caching solution.
Now, let’s create a method to get the cached data:
public T GetCachedData<T>(string key)
{
var jsonData = _cache.GetString(key);
if (jsonData == null)
return default(T);
return JsonSerializer.Deserialize<T>(jsonData);
}
The GetCachedData() method allows us to retrieve cached data of any specified type T. We pass a cache key as a parameter to this method. It retrieves the data present in the cache associated with the key. If no such data is present, it returns the default value of the specified type T.
Next, let’s create a method to store any data in the cache:
public void SetCachedData<T>(string key, T data, TimeSpan cacheDuration)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = cacheDuration
};
var jsonData = JsonSerializer.Serialize(data);
_cache.SetString(key, jsonData, options);
}
In the SetCachedData() method we have three parameters, key, for the identifier of the cached data, data, representing the actual data we want to cache, and cacheDuration to specify the duration for which we want to cache the data.
We serialize the data into JSON before storing it in the cache. It remains in the cache for the specified duration after which it expires.
Use the Caching Service
Let’s update the razor page Index.cshtml to use our newly created RedisCacheService:
public class IndexModel : PageModel
{
private readonly GamesService _gamesService;
private readonly RedisCacheService _cache;
public Game[]? Games { get; set; }
public bool IsFromCache { get; set; } = false;
public IndexModel(GamesService gamesService, RedisCacheService cache)
{
_gamesService = gamesService;
_cache = cache;
}
}
Before we use the service, we need to inject the RedisCacheService into the IndexModel class constructor. Additionally, let’s also introduce a property IsFromCache. This will tell us whether the data in the last request was retrieved from the data source or the cache memory.
Also, let’s modify the OnPostListGames() method to utilize the RedisCacheService:
public void OnPostListGames()
{
var instanceId = GetInstanceId();
var cacheKey = $"Games_Cache_{instanceId}";
Games = _cache.GetCachedData<Game[]>(cacheKey);
if (Games == null)
{
Games = _gamesService.LoadGames();
_cache.SetCachedData(cacheKey, Games, TimeSpan.FromSeconds(60));
IsFromCache = false;
}
else
{
IsFromCache = true;
}
}
First, we generate a cache key. It is important that we make this key unique.
Next, we check if any data associated with the key is already present in the cache using the GetCachedData() method. If so, we assign the data to the Games property.
If no data is cached yet, we send a request to our data source using the LoadGames() method. Then, we store this data in the cache using the SetCachedData() method and associate it with our cache key. Here, we set the expiration time of the cached data to 60 seconds.
Additionally, we set the IsFromCache property to true or false depending on where we retrieve the data from. So, with the click of a button, we send a POST request to the page model and load the data either from the cache or the data source.
To ensure our cache key is unique, we can utilize HttpContext.Session to store an identifier of the current instance:
private string GetInstanceId()
{
var instanceId = HttpContext.Session.GetString("InstanceId");
if (string.IsNullOrEmpty(instanceId))
{
instanceId = Guid.NewGuid().ToString();
HttpContext.Session.SetString("InstanceId", instanceId);
}
return instanceId;
}
The GetInstanceId() method makes sure that even if we launch multiple instances of our application using different web browsers simultaneously, the application assigns a unique id to each of those instances. Hence, the cache for one browser doesn’t interfere with another.
Redis Caching in C# In Action
Let’s update the Index page to provide the user with a message indicating where the requested data is actually coming from:
<div class="text-center mt-5">
<h2 class="display-4">Mission Successful!</h2>
@if (Model.IsFromCache)
{
<p class="lead">Loading games from the cache</p>
}
else
{
<p class="lead">Loading games from the API</p>
}
</div>
When a user clicks on the button to list all the games, if the application loads the data from the cache, the user will see the message “Loading games from the cache”. Whereas, if the source is GamesService, the user will see the message “Loading games from the API” along with the list of games.
We can check available data in the Redis database on the command prompt through the interactive shell we launched inside the Redis container earlier:
docker exec -it redis-cache sh # redis-cli
On running this command, we get a command line interface for Redis. Next, we can scan the database to see any present records:
127.0.0.1:6379> scan 0
At this point, our Redis database is empty:
1) "0" 2) (empty array)
Hence, the API will always handle that initial request. However, as soon as we send the first request, we also create an entry in the cache:
127.0.0.1:6379> scan 0 1) "0" 2) 1) "GamesCatalog_Games_Cache_b7ddc896-589d-468c-8c00-303b608bf36c" 2) "GamesCatalog_4e91c363-6a1a-3268-9ed8-acf2fc8c9fda"
The first key is the key generated to store our game data in the cache.
The session middleware uses our registered cache service to store data. As we have registered Redis as our cache service, it automatically starts using Redis for the storage of Session data. This is the second key we see when scanning.
We can use the hgetall <key> command to see how the cache stores the game data:
127.0.0.1:6379> hgetall GamesCatalog_Games_Cache_b7ddc896-589d-468c-8c00-303b608bf36c
1) "absexp"
2) "638227980499174226"
3) "data"
4) "[{\"Id\":1,\"Title\":\"The Witcher 3: Wild Hunt\",\"Genre\":\"Action RPG\",\"Platform\":\"PlayStation 4\",\"ReleaseYear\":2015},{\"Id\":2,\"Title\":\"Red Dead Redemption 2\",\"Genre\":\"Action Adventure\",\"Platform\":\"Xbox One\",\"ReleaseYear\":2018},{\"Id\":3,\"Title\":\"The Legend of Zelda: Breath of the Wild\",\"Genre\":\"Action Adventure\",\"Platform\":\"Nintendo Switch\",\"ReleaseYear\":2017},{\"Id\":4,\"Title\":\"God of War\",\"Genre\":\"Action Adventure\",\"Platform\":\"PlayStation 4\",\"ReleaseYear\":2018},{\"Id\":5,\"Title\":\"Assassin\u0027s Creed Valhalla\",\"Genre\":\"Action RPG\",\"Platform\":\"PC\",\"ReleaseYear\":2020},{\"Id\":6,\"Title\":\"Halo Infinite\",\"Genre\":\"First-person shooter\",\"Platform\":\"Xbox Series X/S\",\"ReleaseYear\":2021},{\"Id\":7,\"Title\":\"Cyberpunk 2077\",\"Genre\":\"Action RPG\",\"Platform\":\"PC\",\"ReleaseYear\":2020},{\"Id\":8,\"Title\":\"Super Mario Odyssey\",\"Genre\":\"Platform\",\"Platform\":\"Nintendo Switch\",\"ReleaseYear\":2017},{\"Id\":9,\"Title\":\"Call of Duty: Warzone\",\"Genre\":\"Battle royale\",\"Platform\":\"PlayStation 5\",\"ReleaseYear\":2020},{\"Id\":10,\"Title\":\"Minecraft\",\"Genre\":\"Sandbox\",\"Platform\":\"Multiplatform\",\"ReleaseYear\":2011}]"
5) "sldexp"
6) "600000000"
So, for the next 60 seconds, Redis serves the subsequent requests. Once the cache expires, the API will again serve the initial request with Redis taking over for the duration of cache expiration.
Cache Expiration Policies With Redis in C#
So, we have this fast and efficient Redis cache for our application. However, we need to manage what data to keep and what to discard from it. This is where cache expiration policies come into the picture.
Cache expiration policies work like time limits that we set for each item in our Redis cache. Once the expiration time is up, the item is automatically removed.
Let’s look at some of the types of cache expiration policies in Redis.
Absolute Expiration
This allows us to set a specific date and time after which the cached data expires. We can set an absolute expiration using the AbsoluteExpiration property:
public void SetCachedData<T>(string key, T data, TimeSpan cacheDuration)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpiration = DateTime.Now.Add(cacheDuration)
};
var jsonData = JsonSerializer.Serialize(data);
_cache.SetString(key, jsonData, options);
}
Here, we specify a point in time as a deadline after which the cached data is no longer valid.
Sliding Expiration
When we don’t want to depend on the age of data in the cache and rather its frequency of use, sliding expiration is useful.
The video’s sliding-expiration example also sets AbsoluteExpiration alongside it, so it never actually isolates sliding expiration — the snippet below sets only SlidingExpiration to show the real behavior.
We can use the SlidingExpiration property to set the sliding expiration:
public void SetCachedData<T>(string key, T data, TimeSpan cacheDuration)
{
var options = new DistributedCacheEntryOptions
{
SlidingExpiration = cacheDuration
};
var jsonData = JsonSerializer.Serialize(data);
_cache.SetString(key, jsonData, options);
}
Here, the expiration time resets for the same duration each time we access the data within the specified duration. For example, if we have a cache with a sliding expiration of 60 seconds, the expiration time keeps extending by 60 seconds every time we either access or modify the data within the expiration duration.
Time-to-Live (TTL) Expiration
This is similar to absolute expiration. However, instead of setting an absolute time, we use a relative maximum duration till which the data is valid. We can set it using the AbsoluteExpirationRelativeToNow property:
public void SetCachedData<T>(string key, T data, TimeSpan cacheDuration)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = cacheDuration,
SlidingExpiration = cacheDuration
};
var jsonData = JsonSerializer.Serialize(data);
_cache.SetString(key, jsonData, options);
}
Here, we pass a duration of time using the cacheDuration parameter. Once this duration elapses from the time the data is stored in the cache, the data expires.
When we set both a TTL and a sliding expiration, the absolute deadline caps the sliding window. Each access still extends the entry, but never past that deadline. The provider writes the key with the shorter of the two durations, and on every subsequent read it resets the expiry to whichever is smaller: the time left until the absolute deadline, or a fresh sliding window.
Manual Removal of Cache
If we don’t specify any expiration policy, the data lives in the cache indefinitely till we manually remove it. This might be useful if we want to persist some data regardless of how old or infrequently used it is. However, this can lead to our cache being filled with unused and outdated data.
Let’s implement a button to manually remove the cached data in Index.cshtml:
<form method="post">
<button type="submit" class="btn btn-danger btn-lg" asp-page-handler="RemoveCache">
Remove Cached Data
</button>
</form>
Additionally, we need to add a method in RedisCacheService to remove the cache:
public void RemoveCachedData(string key)
{
_cache.Remove(key);
}
The method takes the cache key as an argument and removes it from the Redis cache. On click of the “Remove Cached Data” button, we send a POST request to get the existing key and remove it:
public void OnPostRemoveCache()
{
var instanceId = GetInstanceId();
var cacheKey = $"Games_Cache_{instanceId}";
_cache.RemoveCachedData(cacheKey);
OnPostListGames();
}
This forces the application to load data using the RedisCacheService on the next request and the application shows the message “Loading games from the API” even though the cache expiration time isn’t elapsed.
Best Practices for Redis in C#
Let’s explore some of the best practices that can help us make decisions to utilize Redis more efficiently.
One of the most important caching strategies is to select which data to cache. We should cache parts of our application that are read-heavy and frequently accessed. Data that is updated frequently is not a good choice here as the cache might not hold the most updated data always.
To prevent the cache data from going stale, it’s also important to set an appropriate cache expiration duration. This ensures that the data in the cache is up to date and minimizes the need to retrieve data from the original data source. Redis allows us to set the cache expiration using a TTL (time-to-live) key. However, we need to strike a balance here. A short cache expiration will ensure data freshness but might do so at the cost of higher cache misses.
When the underlying data is updated, the cache should also reflect the same and should get rid of the previous outdated data. We call this process cache invalidation, and it’s important to keep the cache consistent. This ensures the application doesn’t serve outdated data to the users.
Finally, similar to the application, caching is a growing process. We need to keep refining our caching strategies based on how the data and the application scale.
Two neighbouring tools are worth knowing before we settle on a strategy: EasyCaching, which wraps several providers behind one API, and output caching, which caches the response rather than the data.
Should We Use Redis or In-Memory Caching?
We use IMemoryCache until the application runs as more than one instance, and Redis from that point on.
A single-process application gets nothing from Redis that it cannot get from memory, and pays a network round trip plus serialisation on every hit. The in-process cache stores object references, so a hit costs nothing but a dictionary lookup.
The moment there are two instances, in-process caching starts producing contradictions. Each instance caches its own copy, they expire at different times, and a user’s answer depends on which server the load balancer picked. Redis holds one copy that every instance reads, and it survives a restart or a deployment.
Since .NET 9 (GA March 2025), HybridCache is a third option that keeps an in-process layer in front of a distributed one so hot keys skip the round trip. Microsoft’s HybridCache documentation says it “ensures that only one concurrent caller for a given key calls the factory method”.
The cost of Redis is a server to run, monitor, and secure.
| Criterion | IMemoryCache | Redis (IDistributedCache) | HybridCache |
|---|---|---|---|
| Where the data lives | In the app process | Separate Redis server | Both: in-process L1 over a distributed L2 |
| Survives an app restart | No | Yes | L2 yes, L1 no |
| Shared across instances | No | Yes | Yes, through L2 |
| Read cost on a hit | Nanoseconds, no serialisation | Network round trip plus deserialisation | L1 hit avoids the round trip |
| Values are stored as | Object references | Bytes; we serialise | Serialised into L2, objects in L1 |
| Extra infrastructure | None | A Redis server to run and monitor | Whatever L2 needs |
| Stampede protection | We write it ourselves | We write it ourselves | Built in — only one concurrent caller per key runs the factory method |
| Use it when | Single instance, cheap-to-rebuild data | Multiple instances, or the data must outlive a restart | Multiple instances and a hot key set worth keeping locally |
Each row of that table points at one of the three: in-memory caching in ASP.NET Core for a single process, Redis for anything behind a load balancer, and Hybrid Caching in ASP.NET Core when we want both layers and the stampede protection that comes with it.
Conclusion
In this article, we learned about caching and how to implement it using Redis in C#. We looked at how to configure Redis for a .NET web application and also learned about Redis cache expiration policies and some of the best practices for Redis caching.
Tested with .NET 10.0.10.
