Sunday, September 1, 2024

Client Caching in Asp DotNet Core

Page caching appears to be a slightly vexed topic in ASP NET Core. I think a significant factor is that a lot of the discussions around caching fail to make the distinction between Client Side and Server Side caching. So what are these?

Client Caching

Client side caching occurs only on the browser. In chrome, if we switch to the network tab and load a page of an AspNet Website, we can see caching in action:


Static assets are all cached by default - so things like jquery, boostrap, images and css will be downloaded once and from then on fetched from the browser's local cache, which dramatically improves page load speed and reduces bandwidth. Conversely, dynamic endpoints, typically methods of a controller (here the original page /Home/Person/4819, and the listview-cache.js calls), are not cached. It is assumed that dynamic endpoints may change each call, so caching would be inappropriate.  

While this is generally true, for web based database systems there are often long lists of data, sometimes10+Mb of data, that changes infrequently and must be added to every page load. It would be good to be able to cache these lists, even though they need to be generated by a dynamic endpoint.

While it may seemunnecessary in today's high speed world, sometimes these apps are used remotely over a slow VPN connection and in that scenario every kilobyte counts.  An alternative to caching that is often adopted is AJAX partial loading, however this is not much more efficient and on slow connections can severely affect responsiveness - personally, I hate AJAX loading. The best model IMO is one that has a long initial page load, but then only short delays for small AJAX payloads and short subsequent page loads - that is, cached data.

Server Caching

Server caching is a completely different beast. Server caching does not affect bandwidth at all, but rather server load. The idea is that if a server has to perform a lot of calculations to render a page, but then that page remains valid and can be re-used, it can be cached on the server. The server middleware can then return the cached version of the page rather than using its resources in re-rendering the page.

In AspNet Core, ResponseCaching is the service that handles server side caching, and has recently been superseded (I think, although it may be augmented) by OutputCaching.  Most of my apps are internal to organisations, have less than 100 users and server load is almost negligible, so I will never care about Output/Response Caching, but if you had a high traffic public website it could be crucial to your performance tuning.

There is a very important rule when using Server Side Caching on authorized endpoints with this library: NEVER cache sensitive data that is specific to a user - cached data should be data that will be used for every authorized user on the website.

To game this out, consider user X is logged in to the site (authorized) and goes to endpoint A: https://mysite.com/myaccountsettings

Now user Y logs in and also goes to endpoint A. Rather than regenerating the page, the server serves up the cached page previouly served to user X.  

You can see why this is a problem on a number of levels - not the least being security. And this is why ResponseCaching in AspNet is completely disabled for authorised endpoints.

Will the Real Caching Please Stand Up?

I think a lot of the confusion around caching is caused firstly because very few people stop to clarify whether the conversation is around server or client caching, and secondly because ResponseCaching is essentially a server caching service that also has settings that control client caching.

Googling anything to do with Client Caching for Authorized Endpoints in AspNet Core returns a page full of references to ResponseCaching. You really have to search through the results to find anything specific to client side caching. It is easy to think, as I did, that the only way to achieve client side caching is with ResponseCaching.

Turns Out, It's Easy

So finally, after checking out all the links, lodging this request with the AspNet Core team, and actually cloning and modifying the repo to add a flag to allow Response Caching on authorized endpoints, I learned more about caching and came up with this question on StackOverflow

In the end, it's just a matter of adding the cache-control header to the response. It can also be done directly like this, but I found using an attribute decoration to be what I was after. I had assumed that the middleware would specifically remove such a header for authorized endpoints, but it turns out I was wrong.

I wasn't looking for any HTTP error code interactions, so I simplified it down to this:

using System;
using System.Text;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
namespace AspMvcApp {
  // https://stackoverflow.com/questions/67901155/why-is-asp-net-core-setting-cache-control-headers-on-error-responses

  public class CacheControlAttribute : ActionFilterAttribute {
    public int DurationSec { get; set; } = 0;

    public override void OnActionExecuted(ActionExecutedContext context) {
        SetCacheControlHeaders(context.HttpContext.Response);
    }

    private void SetCacheControlHeaders(HttpResponse response) {
      response.Headers[HeaderNames.CacheControl] = $"private,max-age={DurationSec}";
    }
  }
}
And here is the authorized cached endpoint:
namespace AspMvcApp.Controllers { 
  [Authorize]
  [CacheControl(DurationSec = 2629746)]  // cache for a month
  public class ScriptController : Controller {
      [Route("Script/list-view-cache.js")]
      public IActionResult list_view_cache(string hash) {
        var scriptText= ListViewCache.GetDataTableCacheItemByHash(hash).DataSerialised;
        return Content(scriptText, "text/javascript");
      }
  }
}
which yields our cached dynamic endpoints like a charm:


But You Can Still Have Your Server Side Caching If You Want

Since I only want Client Caching, I no longer need the server side code, but before I discovered this, I had modded the existing AspNet Core ResponseCache to add the AllowAuthorizedEndpoints flag.

There is still a case for allowing server side caching, only where there is a dynamically generated resource that is used by all authorised users and can be frozen for a period of time.
If you need it, I've created a GitHub repo here. It has examples and a small amount of documentation.
Clearly this repo won't evolve with the ongoing AspNet project, so use at your own risk. It would be good if the team would see fit to include the PR, but I can't see it happening any time soon.
They have recommended using Output Caching as a more flexible alternative, so the same thing may be possible with that API. Feel free to comment if you are familiar with the OutputCaching service.