Programming

What is Cache Busting with complete implementation in ASP.NET Core

Pinterest LinkedIn Tumblr

Cache busting is the process of replacing an existing asset in cache with a newer version. In this article we see how it works, when we should use it and how we can implement various techniques in ASP.NET.

When we should use cache busting

If your application includes a web interface, then its very likely that you have to use assets like images, style sheets and JavaScript files. Also those files needs to be updated like the backend of the application.

When a new version is rolled out on the backend, the user experiences the new version right away, but in a web front-end environment, cache can prevent users to interact with the latest versions of the application’s assets. You can force invalidate yours browser’s cache with ctrl+f5 but for the end-user we should put another technique in place.

First of all, static assets should almost always have long cache expiration headers. In this way the browser will cache all long lasting assets of your application improving the performance and reducing redundant requests. On the other hand, application styles and JavaScript files should be replaced as soon as a newer version is available.

How to implement Cache Busting

There are a few techniques that we can use in order to implement Cache Busting.

Adding a query string to the URL of the asset

It can be any string just enough to show a new version. Even better the git commit SHA can be used as identifier. In this technique the server-side application updates the following URL:

<link rel="stylesheet" type="text/css" href="../dist/fireman.css" />

to this:

<link rel="stylesheet" type="text/css" href="../dist/fireman.css?v=12f44a23" />

The browser will notice the change and will load the new asset.

It should be mentioned though, that this method could potentially lead to version inconsistency in case your application is served by multiple instances. There might be a time window where an updated instance requests the newer asset but the server/instance that ends up deliver this newer version hasn’t been updated yet. Next we present a way to mitigate that risk.

Session affinity

In order to solve version inconsistency you can use Session affinity so that all requests from the same user go to the same server/instance.

Add version number into the URL or filename

This is a little different that the previous method, because in this way we can have both the old and new versions in different directories. An example is shown below:

<link rel="stylesheet" type="text/css" href="../12f44a23/dist/fireman.js" />

Another advantage of this method is version consistency.

ASP.NET Core Cache Busting

The way we can implement cache busting in a ASP.NET Core application is by using the asp-append-version tag helper or to implement our own solution as we will explore in the next sections.

asp-append-version Tag Helper

We can use the asp-append-version in scripts where we want to append a version number as a query parameter like this:

<link rel="stylesheet" asp-append-version="true" href="~/css/site.css" />

The above code will result a query string to be appended into the served asset:

cache-busting-query-version
The appended version number as a query string parameter

Handle caching of Static files

For static files we want the browser to cache them as long as possible. This way we improve performance and avoid redundant requests. For ASP.NET Core we can create a cache configuration for the static files (in Startup.cs) as shown below:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
   // ...
   var options = new StaticFileOptions
   {
      OnPrepareResponse = context => context.Context.Response.GetTypedHeaders()
         .CacheControl = new CacheControlHeaderValue
	{
	   Public = true,
	   MaxAge = TimeSpan.FromDays(365 * 10) // 10 years
	}
   };
   app.UseStaticFiles(options);
   // ...
}

Create a Cache Busting Solution in ASP.NET Core

Finally, we will create a custom Tag Helper in order to incorporates the version in each asset. We will make a configurable solution that includes both query string and file path versioning implementations, in addition to MD5 and git commit hash fingerprints.

How to obtain git commit hash in ASP.NET Core

In order to use the Git commit hash as the version we will use a pre-build event in project properties to get the Git commit hash every time the application is build. The hash is written in a file version.txt which is located in wwwroot of the application.

The prebuild event will contain the following command:

"C:\Program Files\Git\bin\git.exe" rev-parse --short HEAD > "$(ProjectDir)\wwwroot\version.txt"

Fingerprint

With Fingerprint we mean the version that each asset will have in their URL.

The two implementations of versions/fingerprints will be git commit hash and MD5 hash of the asset. All Fingerprint strategies extends from the Fingerprint abstract class as shown below:

public class Fingerprint
{
   public virtual string Get(string fileName) { return string.Empty; }
}

The fileName is the value of href or src attributes of the link and script elements.

Next we have the MD5Fingerprint and GitFingerprint implementations:


public class MD5Fingerprint : FileFingerprint
{
   public MD5Fingerprint(IWebHostEnvironment env, IUrlHelper urlHelper) : base(env, urlHelper) { }

   public override string Get(string fileName)
   {
      if (!TryReadFile(fileName, out FileInfo file)) return "";

      using (var md5 = MD5.Create())
      {
         using (var stream = File.OpenRead(file.FullName))
         {
            var hash = md5.ComputeHash(stream);
            return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
         }
      }
   }
}

public class GitFingerprint : FileFingerprint
{
   private readonly string versionFile = "version.txt";

   public GitFingerprint(IWebHostEnvironment env, IUrlHelper urlHelper) : base(env, urlHelper) { }

   public override string Get(string fileName)
   {
      if (!TryReadFile(versionFile, out FileInfo file)) return "";

      return File.ReadAllText(file.FullName).Trim();
   }
}

Because both implementations needs to access files we make another intermediate abstract class – FileFingerprint – to encapsulate that behavior.

public abstract class FileFingerprint : Fingerprint
{
   private readonly IWebHostEnvironment _env;
   private readonly IUrlHelper _urlHelper;

   public FileFingerprint(IWebHostEnvironment env, IUrlHelper urlHelper)
   {
      this._env = env;
      this._urlHelper = urlHelper;
   }

   protected bool TryReadFile(string fileName, out FileInfo file)
   {
      var path = _urlHelper.Content(fileName);
      path = this._env.WebRootPath + "\\" + path;
      file = new FileInfo(path);
      if (!file.Exists) return false;
      return true;
   }
}

The FileFingerprint class can check the file name and return a FileInfo object.

Incorporate the version into the asset

Next, we have two implementations for incorporating a version into each asset. One is the QueryStringFingerprintManager and the other the PathFingerprintManager. Both of them are implementing the abstract class FingerprintManager which constructs and returns the new versioned URL.

public abstract class FingerprintManager
{
   private readonly Fingerprint _fingerprint;

   public FingerprintManager(Fingerprint fingerprint)
   {
      _fingerprint = fingerprint;
   }

   public string Construct(string fileName)
   {
      string identifier = this._fingerprint.Get(fileName);
      var res = this.DoConstruct(fileName, identifier);
      return res;
   }

   protected abstract string DoConstruct(string fileName, string identifier);
}

public class QueryStringFingerprintManager : FingerprintManager
{
   public QueryStringFingerprintManager(Fingerprint fingerprint) : base(fingerprint)
   { }

   protected override string DoConstruct(string fileName, string identifier)
   {
      return $"{fileName}?v={identifier}";
   }
}

public class PathFingerprintManager : FingerprintManager
{
   public PathFingerprintManager(Fingerprint fingerprint) : base(fingerprint)
   { }

   protected override string DoConstruct(string fileName, string identifier)
   {
      if (fileName.StartsWith("/"))
         return "/" + identifier + fileName;
      else
         return "/" + identifier + "/" + fileName;
   }
}

The QueryStringFingerprintManager is responsible to transform this:

<link rel="stylesheet" cache-href="/css/site.css" />

to this:

<link rel="stylesheet" cache-href="/css/site.css?v=347ab83f" />

And the PathFingerprintManager can convert the first to this:

<link rel="stylesheet" cache-href="/347ab83f/css/site.css" />

Create the Cache Tag Helper

In order to utilize the previous modules we create a custom Tag Helper class named CacheTagHelper. For each script or link element it will make the following steps:

  1. Get the fileName of the cache-href or cache-src attribute.
  2. Construct a fingerprint (MD5 or Git commit hash) from the fileName.
  3. Add a new attribute src or href into that element of the versioned asset.
  4. Remove/clean the previous cache-href or cache-src attributes from the element.
[HtmlTargetElement("link", Attributes = "cache-href")]
[HtmlTargetElement("script", Attributes = "cache-src")]
public class CacheTagHelper : TagHelper
{
   private readonly FingerprintManager _fingerprintManager;

   public CacheTagHelper(FingerprintManager fingerprintAppender)
   {
      _fingerprintManager = fingerprintAppender;
   }

   public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
   {
      CacheAttribute cacheAttribute = CacheAttribute.Create(context.TagName);

      string attrValue = cacheAttribute.Extract(output);

      string versioned = this._fingerprintManager.Construct(attrValue);

      cacheAttribute.Add(output, versioned);
      cacheAttribute.Remove(output);

      return Task.CompletedTask;
   }
}

Also, we encapsulate the responsibility of manipulating the element (adding, removing attributes from it) into the CacheAttribute class as shown below:

public class CacheAttribute
{
   private readonly string attr;

   public CacheAttribute(string attr)
   {
      this.attr = attr;
   }

   public static CacheAttribute Create(string tagName) {
      switch (tagName)
      {
         case "link":
            return new CacheAttribute("href");
         case "script":
            return new CacheAttribute("src");
         default:
            throw new NotSupportedException();
      }
   }

   public string Extract(TagHelperOutput output)
   {
      return output.Attributes.FirstOrDefault(x => x.Name == $"cache-{attr}")?.Value?.ToString();
   }

   public void Add(TagHelperOutput output, string versioned)
   {
      output.Attributes.SetAttribute(attr, versioned);
   }

   public void Remove(TagHelperOutput output)
   {
      output.Attributes.RemoveAll($"cache-{attr}");
   }
}

Bringing It All Together

Lastly, we have to register the services and provide a logic that registers the proper services according to the input from appsettings (or other environment variables).

public static class IServiceCollectionExtensions
{
   public static void AddUrlHelper(this IServiceCollection services)
   {
      services.TryAddSingleton<IActionContextAccessor, ActionContextAccessor>();
      services.TryAddSingleton<IUrlHelperFactory, UrlHelperFactory>();

      services.TryAddScoped<IUrlHelper>(
         factory => factory.GetRequiredService<IUrlHelperFactory>()
            .GetUrlHelper(factory.GetRequiredService<IActionContextAccessor>().ActionContext)
      );
   }

   public static void AddCacheBusting(this IServiceCollection services, IConfiguration conf)
   {
      string appender = conf.GetValue<string>("CacheBusting:Appender");
      string version = conf.GetValue<string>("CacheBusting:Version");

      if (appender == null || appender == "QueryString")
         services.AddScoped<FingerprintManager, QueryStringFingerprintManager>();
      else if (appender == "FilePath")
         services.AddScoped<FingerprintManager, PathFingerprintManager>();

      if (version == null || version == "MD5")
         services.AddScoped<Fingerprint, MD5Fingerprint>();
      else if (version == "Git")
         services.AddScoped<Fingerprint, GitFingerprint>();
   }
}

And we call those extension methods in Startup.cs

public void ConfigureServices(IServiceCollection services)
{
   services.AddRazorPages();
   services.AddUrlHelper();
   services.AddCacheBusting(Configuration);
}

Examples

Finally, we can make the following combinations:

FingerprintFingerprintManagerAppsettings
MD5QueryString“CacheBusting”: {
“Appender”: “QueryString”,
“Version” : “MD5”
}
GitQueryString“CacheBusting”: {
“Appender”: “QueryString”,
“Version” : “Git”
}
GitFilePath“CacheBusting”: {
“Appender”: “FilePath”,
“Version” : “Git”
}
All possible combinations for cache busting.

We don’t make the combination of incorporating the MD5 hash into filepath because it’s not practical, unless we implement URL rewriting.

We can also inspect the results of using the Git commit hash in the query string parameter:

And finally the results of using Git commit hash in filepath:

Resources / Recommendations

For more information about deployments and many production related issues I highly recommend the following book:

Write A Comment

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.