In this article, we are going to discuss how response caching works in ASP.NET Core.

To download the source code for this article, you can visit our GitHub repository.

Let’s get started.

What is Response Caching?

Response Caching is the mechanism of caching a server’s response by a browser or other clients. This will help in serving future requests for the same resources very quickly. Additionally, this will free up the server from processing and generating the same response multiple times.

ASP.NET Core uses the ResponseCache attribute to set the response caching headers. Furthermore, we can use the Response Caching Middleware to control the caching behavior from the server-side. Once we set the response caching headers, clients and other proxies can read those to determine how to cache the response from the server. As per the HTTP 1.1 Response Cache Specification, browsers, clients and proxies should conform to the caching headers

HTTP Based Response Caching

Now, let’s talk about different HTTP Cache Directives and how we can control the caching behavior using those directives.

The cache-control is the primary header field that we use to specify the way response can be cached. When the cache-control header is present in the response, browsers, clients, and proxy servers should honor the headers and comply with them.

So, let’s inspect the common cache-control directives:

  • public – indicates that a cache can store the response either at the client-side or at a shared location
  • privateindicates that only a private cache on the client-side may store the response, but not a shared cache
  • no-cache – specifies that a cache must not use a stored response for any requests
  • no-store – indicates that a cache must not store the response

no-cache and no-store may sound similar and even behave similarly, but there are some differences in the way browsers or clients understand both. We are going to discuss this in detail while working on the examples.

Apart from the cache-control, there are a few other headers that can control the caching behavior:

The pragma header is for backward compatibility with HTTP 1.0 specification and the no-cache directive. If we specify the cache-control header, it will ignore the pragma header.

Vary indicates that it can send a cached response only if all the fields that we provide in the header match exactly with the previous request. If any of the fields changes, the server generates a new response.

HTTP Cache Directive Examples

Now, we are going to create an ASP.NET Core application and see the cache directives in action. Let’s create an ASP.NET Core Web API project and add a controller action method:

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    [HttpGet]        
    public IActionResult Get()
    {
        return Ok($"Response was generated at {DateTime.Now}");
    }
}

Every time we execute this endpoint, we can observe that it appends the actual date and time of execution to the response. Now we are going to add response caching to the endpoint.

Important note: While testing response caching we should always click links on the web page or use swagger to execute the API endpoints in the browser. Otherwise, if we try to refresh the page or invoke the URI again, the browser will always request a new response from the server irrespective of the response cache settings.

ResponseCache Attribute

For an ASP.NET Core application, the ResponseCache attribute defines the properties for setting the appropriate response caching headers. We can apply this attribute either at the controller level or for individual endpoints.

Let’s add the ResponseCache attribute to the API endpoint:

[ResponseCache(Duration = 120, Location = ResponseCacheLocation.Any)]
public IActionResult Get()
{
    return Ok($"Response was generated at {DateTime.Now}");
}

This Duration property will produce the max-age header, which we use to set the cache duration for 2 minutes (120 seconds). Similarly, the Location property will set the location in the cache-control header. Since we set the location as Any, both the client and server will be able to cache the response, which is equivalent to the public directive of the cache-control header.

So, let’s invoke the API endpoint and verify these in the response headers:

cache-control: public,max-age=120

Additionally, when we invoke the endpoints multiple times, whenever the browser uses a cached response, it will indicate in the status code that the response is taken from the disk cache:

Status Code: 200 (from disk cache)

Now let’s explore the different options for the ResponseCache parameters.

For changing the cache location to private, we just need to change the Location property’s value to ResponseCacheLocation.Client

[ResponseCache(Duration = 120, Location = ResponseCacheLocation.Client)]

This will change the cache-control header value to private which means that only the client can cache the response:

cache-control: private,max-age=120

Now let’s update the Location parameter to ResponseCacheLocation.None:

[ResponseCache(Duration = 120, Location = ResponseCacheLocation.None)]

This will set both the cache-control and pragma header to no-cache, which means the client cannot use a cached response without revalidating with the server: 

cache-control: no-cache,max-age=120
pragma: no-cache

In this configuration, we can verify that the browser does not use the cached response and the server generates a new response every time.

We also have articles on Distributed Caching and In-Memory Caching. So, if you like to learn more about the caching topic, feel free to visit those articles.

NoStore Property

Now let’s set the NoStore property of the ResponseCache attribute to true:

[ResponseCache(Duration = 120, Location = ResponseCacheLocation.Any, NoStore = true)]

This will set the cache-control response header to no-store which indicates that the client should not cache the response:

cache-control: no-store

Notice that this will override the value that we set for Location. In this case, as well, the client will not cache the response.

Even though the no-cache and no-store values for cache-control may give the same results while testing, the browsers, clients, and proxies interpret these headers differently. While no-store indicates that the clients or proxies should not store the response or any part of it anywhere, no-cache just means that the client should not use a cached response without revalidating with the server. 

Now let’s see how the vary header works.

VaryByHeader Property

For setting the vary header, we can use the VaryByHeader property of the ResponseCache attribute:

[ResponseCache(Duration = 120, Location = ResponseCacheLocation.Any, VaryByHeader = "User-Agent")]

Here we set the value for the VaryByHeader property to User-Agent, which will use the cached response as long as the request comes from the same client device. Once the client device changes, the User-Agent value will be different and it will fetch a new response from the server. Let’s verify this.

First, let’s check the presence of the vary header in the response headers:

vary: User-Agent

Additionally, on inspecting the request headers, let’s verify the user-agent header, which corresponds to the device and browser that we use:

user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.67 Safari/537.36

For testing purposes, let’s simulate a new request coming from a different device. For that, we can use the toggle device feature available with the developer tools of browsers such as Chrome and Edge:

device toggle toolbar for response caching

After clicking on the toggle device button in the developer tools, we can choose the device that we want to simulate in the browser:

choose device for response caching

This will help us switch the browser to a phone or tablet simulation mode.

Once we switch the device, we can see that the server abandons the cached response and sends a new one. Also, note that the user-agent header will be different:

user-agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1

So by setting the VaryByHeader property as User-Agent, we can make the server send a new response for a new device.

VaryByQueryKeys Property

By using the VaryByQueryKeys property of the ResponseCache attribute, we can make the server send a new response when the specified query string parameters change. Of course, by specifying the value as “*”, we can generate a new response when any of the query string parameters changes.

For example, we might want to generate a new response when the Id value changes in the URI:

.../values?id=1 
.../values?id=2

For this, let’s modify the Get action to include the id parameter and provide the VaryByQueryKeys property for the ResponseCache attribute:

[HttpGet]
[ResponseCache(Duration = 120, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { "id" })]
public IActionResult Get(int id)
{
    return Ok($"Response was generated for Id:{id} at {DateTime.Now}");
}

Remember that we need to enable the Response Caching Middleware for setting the VaryByQueryKeys property. Otherwise, the code will throw a runtime exception.

Response Caching Middleware 

The Response Caching Middleware in ASP.NET Core app determines when a response can be cached, and stores and serves the response from the cache. 

For enabling the Response Caching Middleware, we just need to add a couple of lines of code in the Program class:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddResponseCaching();
...
var app = builder.Build();
...
app.MapControllers();
app.UseResponseCaching();

app.Run();

First, we need to add the middleware using the AddResponseCaching() method and then we can configure the app to use the middleware with the UseResponseCaching() method.

That’s it. We have enabled the Response Caching Middleware and the  VaryByQueryKeys property should work now.

Let’s run the application and navigate to the /values?id=1 endpoint:

Response was generated for Id:1 at 23-05-2022 12:07:22

We can see that we’ll receive a cached response as long as the query string is the same, but once we change the query string, the server will send a new response.

Let’s change the query string to /values?id=2:

Response was generated for Id:2 at 23-05-2022 12:07:32

Note that there is no corresponding HTTP header for the VaryByQueryKeys property. This property is an HTTP feature that is handled by Response Caching Middleware.

With that, we have covered all the common HTTP cache directives.

Cache Profiles

Instead of duplicating the response cache settings on individual controller action methods, we can configure cache profiles and reuse them across the application. Once we set up a cache profile, the application can use its values as defaults for the ResponseCache attribute. Of course, we can override the defaults by specifying the properties on the attribute.

We can define a cache profile in the Program class:

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers(options =>
{
    options.CacheProfiles.Add("Cache2Mins",
        new CacheProfile()
        {
            Duration = 120,
            Location =  ResponseCacheLocation.Any            
        });
});
...

Here we define a new cache profile called Cache2Mins with a duration of 2 minutes and location as public.

Now we can apply this cache profile to any controller or endpoint:

[HttpGet]
[ResponseCache(CacheProfileName = "Cache2Mins")]
public IActionResult Get()
{
    return Ok($"Response was generated at {DateTime.Now}");
}

This will apply the defined cache-control settings for the response:

cache-control: public,max-age=120

Instead of hard-coding the cache settings in the Program class, we can define multiple cache profiles in the appsettings file and make the response cache settings configurable:

{
  "Logging": {
...
  },
  "CacheProfiles": {
    "Cache2Mins": {
      "Duration": 120,
      "Location": "Any"
    }
  },
  "AllowedHosts": "*"
}

Then, we can read the cache profiles in the Program class using the ConfigurationManager class:

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers(options =>
{
    var cacheProfiles = builder.Configuration
            .GetSection("CacheProfiles")
            .GetChildren();

    foreach (var cacheProfile in cacheProfiles)
    {
        options.CacheProfiles
        .Add(cacheProfile.Key, 
        cacheProfile.Get<CacheProfile>());
    }
});

This is a much better way of defining cache profiles, especially when we need to define multiple cache profiles for our application.

Conclusion

In this article, we discussed what response caching is and how it works in ASP.NET Core applications. Additionally, we have learned how to control the response caching behavior by setting the cache directives and using the response caching middleware. Finally, we learned how to define a cache profile and reuse it across the application.