Mert Özen Further With Every Line
Backend .NET Core Web API — #27

Caching in ASP.NET Core Web API: IMemoryCache, Redis and Response Caching

Mert Özen Nov 4, 2026 11 min 2 views
Caching in ASP.NET Core Web API: IMemoryCache, Redis and Response Caching

Caching in ASP.NET Core Web API: absolute and sliding expiration with IMemoryCache, delete-on-write invalidation, IDistributedCache with Redis, and which requests the response caching middleware won't cache and why.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

In the previous post we documented our API with Swagger; clients now know what to call and how. Which means they will call it, and a lot. For data like the product list, which everybody wants and which rarely changes, going to the database and computing the same answer again on every request is like sifting flour from scratch for every single order.

By the end of the post we will have set up the response caching middleware. Now guess: we send four requests in a row, within one minute, to an endpoint marked with [ResponseCache(Duration = 60)]. The first two are plain requests, the third carries a Cache-Control: no-cache header, the fourth Authorization: Bearer abc. How many times does the controller action run? Once, twice, four times?

What Caching Fixes and What It Breaks

A cache keeps the result of an expensive operation for a while and hands it out ready the next time. The gain is obvious: faster responses, less load on the database. The cost is just as obvious: the answer you give is no longer "the truth right now" but "the truth a moment ago". Showing a product whose price just changed with its old price is fine on some screens and a serious problem in the shopping cart.

So think of caching not as a speed knob but as a freshness negotiation. For every caching decision, answer three questions: How often is this data read? How often does it change? Who gets hurt, and how badly, by seeing the old version? Data that is read a lot, changes rarely and can tolerate being a few seconds old is the best customer a cache can have. On the ASP.NET Core side we will see three tools in this post: IMemoryCache, which lives in the app's own memory, IDistributedCache, which several servers can share, and the response caching middleware, which works at the HTTP level.

A First Cache with IMemoryCache

First we need a data source slow enough to be worth caching. Let's write a repository where every query takes half a second:

namespace ProductCatalog.Api.Models;

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

public record ProductDto(int Id, string Name, decimal Price);
using ProductCatalog.Api.Models;

namespace ProductCatalog.Api.Services;

public interface IProductRepository
{
    Task<List<Product>> GetPageAsync(int page, int pageSize, CancellationToken cancellationToken);
    Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken);
    Task<bool> UpdatePriceAsync(int id, decimal price, CancellationToken cancellationToken);
}

public class SlowProductRepository : IProductRepository
{
    private readonly List<Product> _products = new()
    {
        new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m },
        new Product { Id = 2, Name = "Wireless Mouse", Price = 650m }
    };

    public async Task<List<Product>> GetPageAsync(int page, int pageSize, CancellationToken cancellationToken)
    {
        await Task.Delay(500, cancellationToken);
        return _products.Skip((page - 1) * pageSize).Take(pageSize).ToList();
    }

    public async Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken)
    {
        // Simulate a slow database query
        await Task.Delay(500, cancellationToken);
        return _products.FirstOrDefault(p => p.Id == id);
    }

    public async Task<bool> UpdatePriceAsync(int id, decimal price, CancellationToken cancellationToken)
    {
        await Task.Delay(100, cancellationToken);
        var product = _products.FirstOrDefault(p => p.Id == id);
        if (product is null)
        {
            return false;
        }

        product.Price = price;
        return true;
    }
}

We put the caching logic in the service layer, not in the controller. The controller deals with HTTP; where the data comes from and how fresh it is belongs to the service.

using Microsoft.Extensions.Caching.Memory;
using ProductCatalog.Api.Models;

namespace ProductCatalog.Api.Services;

public class ProductService
{
    private readonly IProductRepository _repository;
    private readonly IMemoryCache _cache;

    public ProductService(IProductRepository repository, IMemoryCache cache)
    {
        _repository = repository;
        _cache = cache;
    }

    public async Task<List<ProductDto>> GetPageAsync(int page, CancellationToken cancellationToken)
    {
        var products = await _repository.GetPageAsync(page, 10, cancellationToken);
        return products.Select(p => new ProductDto(p.Id, p.Name, p.Price)).ToList();
    }

    public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
    {
        var key = $"product:{id}";

        // If it is in the cache, skip the repository entirely
        if (_cache.TryGetValue(key, out ProductDto? cached))
        {
            return cached;
        }

        var product = await _repository.GetByIdAsync(id, cancellationToken);
        if (product is null)
        {
            return null;
        }

        var dto = new ProductDto(product.Id, product.Name, product.Price);

        _cache.Set(key, dto, new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
            SlidingExpiration = TimeSpan.FromMinutes(2)
        });

        return dto;
    }

    public async Task<bool> UpdatePriceAsync(int id, decimal price, CancellationToken cancellationToken)
    {
        var updated = await _repository.UpdatePriceAsync(id, price, cancellationToken);
        if (updated)
        {
            // The data changed, drop the old copy
            _cache.Remove($"product:{id}");
        }

        return updated;
    }
}

Registrations and the controller:

using ProductCatalog.Api.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IProductRepository, SlowProductRepository>();
builder.Services.AddScoped<ProductService>();

var app = builder.Build();

app.MapControllers();

app.Run();
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Api.Models;
using ProductCatalog.Api.Services;

namespace ProductCatalog.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly ProductService _productService;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(ProductService productService, ILogger<ProductsController> logger)
    {
        _productService = productService;
        _logger = logger;
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken cancellationToken)
    {
        var product = await _productService.GetByIdAsync(id, cancellationToken);
        if (product is null)
        {
            return NotFound();
        }

        return product;
    }

    [HttpPut("{id:int}/price")]
    public async Task<IActionResult> UpdatePrice(int id, UpdatePriceRequest request, CancellationToken cancellationToken)
    {
        var updated = await _productService.UpdatePriceAsync(id, request.Price, cancellationToken);
        return updated ? NoContent() : NotFound();
    }
}

public record UpdatePriceRequest(decimal Price);

AddMemoryCache registers an IMemoryCache singleton; there is one cache for the lifetime of the app and every request shares it. ProductService being scoped is not a problem, every instance looks at the same cache. Send two requests and the difference is immediately visible:

curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:5201/api/products/1
200 0.537084s
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:5201/api/products/1
200 0.002307s

The first request paid the repository's half second, the second came back from memory in 2 milliseconds.

Lifetimes: Absolute and Sliding Expiration

When you put an entry into the cache, you also have to say when it leaves; otherwise it stays in memory forever. AbsoluteExpirationRelativeToNow is a hard expiry date: no matter how often the entry is read, it drops after 10 minutes. SlidingExpiration is an idle timeout: if the entry isn't read for 2 minutes it drops, and every read restarts the clock.

Here is why we use both together: if you only set sliding, an entry that is read constantly never drops, and one day you serve very old data. The absolute ceiling limits how old the data can get in the worst case. Sliding keeps rarely read entries from occupying memory for nothing. Think of a carton of milk: it has an expiry date printed on it, but once you open it you have to finish it within a few days.

Cache Invalidation: When the Data Changes

The hard part of caching is not putting things in, it is knowing when to throw them out. Let's update the price and read it right back:

curl -s -X PUT http://localhost:5201/api/products/1/price -H "Content-Type: application/json" -d '{"price":1399}' -o /dev/null -w "%{http_code}\n"
204
curl -s -w " %{time_total}s\n" http://localhost:5201/api/products/1
{"id":1,"name":"Mechanical Keyboard","price":1399} 0.503322s
curl -s -w " %{time_total}s\n" http://localhost:5201/api/products/1
{"id":1,"name":"Mechanical Keyboard","price":1399} 0.000913s

The first read after the update took half a second again, because _cache.Remove in UpdatePriceAsync dropped the old copy. The new price came from the repository and the next read came from the cache again. Delete that Remove line and, even though the PUT returns 204, the GET keeps showing 1450 for ten minutes.

This approach is called "delete on write". It is simple and reliable, but it has one condition: every path that changes the data must delete the same key. If the price is also changed by an admin panel, an import job or a SQL statement written straight to the database, they know nothing about your Remove. So build your keys in a single place (here, $"product:{id}") and never leave the absolute expiration infinite; that duration is the insurance that limits the damage of a path you forgot.

More Than One Server: IDistributedCache and Redis

IMemoryCache has two limits. First, the cache empties when the app restarts. The second is sneakier: when you run the API as three instances behind a load balancer, each one has its own memory and therefore its own cache. You update the price on one and delete the key, and the other two keep serving the old price.

The fix is moving the cache out of the app into a place everybody shares. ASP.NET Core provides the IDistributedCache abstraction for this; the most common backend is Redis. Add the package with the version that matches your target framework:

# for a net6.0 project
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis --version 6.0.36
# for a net10.0 project
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis --version 10.0.14

In Program.cs, instead of AddMemoryCache:

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "ProductCatalog:";
});
{
  "ConnectionStrings": {
    "Redis": "localhost:6390"
  }
}

Unlike IMemoryCache, IDistributedCache doesn't store objects; it stores byte arrays or strings, because the data travels over the network to another process. We handle serialization ourselves. The part of the service that changes:

using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using ProductCatalog.Api.Models;

namespace ProductCatalog.Api.Services;

public class ProductService
{
    private readonly IProductRepository _repository;
    private readonly IDistributedCache _cache;

    public ProductService(IProductRepository repository, IDistributedCache cache)
    {
        _repository = repository;
        _cache = cache;
    }

    // GetPageAsync stays the same as before

    public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
    {
        var key = $"product:{id}";

        var json = await _cache.GetStringAsync(key, cancellationToken);
        if (json is not null)
        {
            return JsonSerializer.Deserialize<ProductDto>(json);
        }

        var product = await _repository.GetByIdAsync(id, cancellationToken);
        if (product is null)
        {
            return null;
        }

        var dto = new ProductDto(product.Id, product.Name, product.Price);

        await _cache.SetStringAsync(key, JsonSerializer.Serialize(dto), new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
        }, cancellationToken);

        return dto;
    }

    public async Task<bool> UpdatePriceAsync(int id, decimal price, CancellationToken cancellationToken)
    {
        var updated = await _repository.UpdatePriceAsync(id, price, cancellationToken);
        if (updated)
        {
            await _cache.RemoveAsync($"product:{id}", cancellationToken);
        }

        return updated;
    }
}

After sending one request, let's look inside Redis:

redis-cli KEYS '*'
ProductCatalog:product:1
redis-cli HGETALL ProductCatalog:product:1
absexp
639293384395654690
sldexp
-1
data
{"Id":1,"Name":"Mechanical Keyboard","Price":1450}
redis-cli TTL ProductCatalog:product:1
600

InstanceName has been added as a prefix to every key; that matters so you don't collide with another app sharing the same Redis. The entry sits there as a hash: the data, the expiry information and a 600-second TTL. After stopping and restarting the app, the first request came back in 0.05 seconds; no half second from the repository, the cache is still there. If you don't want to install Redis in Development, AddDistributedMemoryCache() implements the same interface in memory. Your code stays the same, only the registration line changes; just don't forget it is not distributed.

The Response Caching Middleware

So far we cached the data; the action ran every time, it just didn't go to the repository. Response caching goes one step further and stores the whole HTTP response. Two lines in Program.cs:

builder.Services.AddResponseCaching();

// After app.Build(), before MapControllers
app.UseResponseCaching();

A list endpoint for the controller:

[HttpGet]
[ResponseCache(Duration = 60, VaryByQueryKeys = new[] { "page" })]
public async Task<List<ProductDto>> GetAll(int page = 1, CancellationToken cancellationToken = default)
{
    _logger.LogInformation("Loading products for page {Page}", page);
    return await _productService.GetPageAsync(page, cancellationToken);
}

[ResponseCache] does two things. It adds the Cache-Control: public,max-age=60 header to the response, telling the browser and any proxies in between "you may keep this for 60 seconds". The middleware looks at the same header and keeps the response in server memory. VaryByQueryKeys makes page=1 and page=2 separate entries; this feature doesn't work without the middleware. Two plain requests:

curl -i "http://localhost:5201/api/products?page=1"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 03 Nov 2026 21:22:50 GMT
Server: Kestrel
Cache-Control: public,max-age=60
Transfer-Encoding: chunked

[{"id":1,"name":"Mechanical Keyboard","price":1450},{"id":2,"name":"Wireless Mouse","price":650}]
curl -i "http://localhost:5201/api/products?page=1"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 03 Nov 2026 21:22:50 GMT
Server: Kestrel
Age: 2
Cache-Control: public,max-age=60
Transfer-Encoding: chunked

[{"id":1,"name":"Mechanical Keyboard","price":1450},{"id":2,"name":"Wireless Mouse","price":650}]

The second response has an Age: 2 header and the Date hasn't changed: this answer was produced two seconds ago and the middleware handed it back as is. The "Loading products" line shows up in the log only once.

The answer to the guess: the action runs three times. The first request fills the cache, the second is served from it. The Cache-Control: no-cache on the third is the client saying "I don't want a stored copy"; the middleware respects that and passes the request on to the controller. The Authorization header on the fourth signals to the middleware that "this response may be personal", and caching is skipped. This is also why caching seems not to work when you hit F5 in the browser: on reload, the browser usually sends Cache-Control: max-age=0. We tried it, and the request with max-age=0 went to the controller too.

Common Pitfalls

When you add Microsoft.Extensions.Caching.StackExchangeRedis 6.0.36 to a net6.0 project, the build gives this warning:

warning NU1904: Package 'System.Drawing.Common' 5.0.0 has a known critical severity vulnerability, https://github.com/advisories/GHSA-rxg9-xrhp-64gj

dotnet nuget why System.Drawing.Common shows the chain: the package depends on StackExchange.Redis 2.2.4, which pulls in this version through a few intermediate packages. Referencing a newer version directly with dotnet add package StackExchange.Redis --version 2.7.27 makes the warning go away; the 10.0.14 package already uses that version.

When Redis is unreachable, the cache is not skipped, the request blows up. When we stopped the container and sent a request, we got a 500 and the log said StackExchange.Redis.RedisConnectionException: It was not possible to connect to the redis server(s). A cache is an accelerator; when it goes down it shouldn't take the app down with it. Wrapping cache calls in try/catch and going straight to the repository on failure is a common fix.

IMemoryCache stores a reference to the object, not a copy. If you change a property on an entity you got from the cache, the cached entry changes too, and everybody sees it. That is why we cached ProductDto, an immutable record, in the example.

As a shortcut, GetOrCreateAsync is very handy, but if the factory returns null it writes that null into the cache as well; you return 404 for a missing product for ten minutes, even if the product is added in the meantime. Sometimes that is exactly what you want, but decide it on purpose.

Don't use response caching for per-user data. The Authorization header protected us once, but an app that carries identity in a cookie doesn't get the same protection. Middleware order matters too: if you use CORS, UseCors must come before UseResponseCaching.

What Comes in Later Versions

The response caching middleware is strictly bound to HTTP caching rules; if the client says no-cache, it skips the server-side cache too. Output caching, which works by the server's own rules, arrived with .NET 7. HybridCache, which keeps hundreds of requests asking for the same key at the same moment from all running to the database and combines in-memory and distributed caching in a single API, belongs to the .NET 9 era. We will look at both in detail in the version posts; the concepts here, meaning keys, lifetimes and invalidation, apply there just the same.

A Small Experiment

★☆☆ Lower SlidingExpiration to 10 seconds and the absolute lifetime to 30 seconds. Send a request every 5 seconds and watch, through the time_total values, the entry drop at the 30th second; then pause the requests for 15 seconds and watch it drop early.

★★☆ Change GetByIdAsync in the Redis version so that when Redis is unreachable it logs the error and reads straight from the repository. Stop the container and see that the request now returns a half-second 200 instead of a 500.

★★★ Run the API as two instances on two different ports. First, with AddMemoryCache, update the price on one, read from the other and see the old price. Then switch to Redis and repeat the experiment.

Our cache layer is ready, but now we also depend on Redis. So how will the load balancer know that the app is up but can't reach Redis or the database? In the next post we move on to health checks: liveness, readiness and a database check.