Cache busting in ASP.NET

Jan 9, 2013

Optimizing for website performance includes setting long expiration dates on our static resources, such s images, stylesheets and JavaScript files. Doing that tells the browser to cache our files so it doesn’t have to request them every time the user loads a page. This is one of the most important things to do when optimizing websites.

In ASP.NET on IIS7+ it’s really easy. Just add this chunk of XML to the web.config’s <system.webServer> element:

<staticContent>
  <clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365:00:00" />
</staticContent>

The above code tells the browsers to automatically cache all static resources for 365 days. That’s good and you should do this right now.

The issue becomes clear the first time you make a change to any static file. How is the browser going to know that you made a change, so it can download the latest version of the file? The answer is that it can’t. It will keep serving the same cached version of the file for the next 365 days regardless of any changes you are making to the files.

Fingerprinting

The good news is that it is fairly trivial to make a change to our code, that changes the URL pointing to the static files and thereby tricking the browser into believing it’s a brand new resource that needs to be downloaded.

Here’s a little class that I use on several websites, that adds a fingerprint, or timestamp, to the URL of the static file.

using System;
using System.IO;
using System.Web;
using System.Web.Caching;
using System.Web.Hosting;

public class Fingerprint
{
  public static string Tag(string rootRelativePath)
  {
    if (HttpRuntime.Cache[rootRelativePath] == null)
    {
      string absolute = HostingEnvironment.MapPath("~" + rootRelativePath);

      DateTime date = File.GetLastWriteTime(absolute);
      int index = rootRelativePath.LastIndexOf('/');

      string result = rootRelativePath.Insert(index, "/v-" + date.Ticks);
      HttpRuntime.Cache.Insert(rootRelativePath, result, new CacheDependency(absolute));
    }

      return HttpRuntime.Cache[rootRelativePath] as string;
  }
}

All you need to change in order to use this class, is to modify the references to the static files.

Modify references

Here’s what it looks like in Razor for the stylesheet reference:

<link rel="stylesheet" href="@Fingerprint.Tag("/content/site.css")" />

…and in WebForms:

<link rel="stylesheet" href="<%=Fingerprint.Tag("/content/site.css") %>" />

The result of using the FingerPrint.Tag method will in this case be:

<link rel="stylesheet" href="/content/v-634933238684083941/site.css" />

Since the URL now has a reference to a non-existing folder (v-634933238684083941), we need to make the web server pretend it exist. We do that with URL rewriting.

URL rewrite

By adding this snippet of XML to the web.config’s <system.webServer> section, we instruct IIS 7+ to intercept all URLs with a folder name containing “v=[numbers]” and rewrite the URL to the original file path.

<rewrite>
  <rules>
    <rule name="fingerprint">
      <match url="([\S]+)(/v-[0-9]+/)([\S]+)"  />
      <action type="Rewrite" url="{R:1}/{R:3}" />
    </rule>
  </rules>
</rewrite>

You can use this technique for all your JavaScript and image files as well.

The beauty is, that every time you change one of the referenced static files, the fingerprint will change as well. This creates a brand new URL every time so the browsers will download the updated files.

FYI, you need to run the AppPool in Integrated Pipeline mode for the <system.webServer> section to have any effect.

* $4.95/month ASP.NET Hosting with FREE SQL 2012 DB! – Click Here!

Comments (35) -

Murilo
Murilo Brazil
1/9/2013 3:17:58 PM #

So, what is the reason of "Last modified"?
If I set 365 days for caching, the browser doesn`t check the last modified date?

Mads Kristensen
Mads Kristensen United States
1/9/2013 7:03:20 PM #

Correct, the "Last-Modified" is used for what is called Cache Validation purposes, but is not read if the file is already in the cache and you don't force a refresh (F5 and shift+F5). So during normal browsing, it has no effect on how the browser serves the file directly from its cache.

vanhauto
vanhauto Belgium
1/10/2013 12:36:45 AM #

That's about the same trick I use.
I also added a switch for debug- or min-files.
For local debugging I use the debug-file. In production- or staging-environment I use the min-file:

    if (HttpRuntime.Cache[rootRelativePath] == null)
    {
      string absolute = HostingEnvironment.MapPath("~" + rootRelativePath);

      DateTime date = File.GetLastWriteTime(absolute);
      int index = rootRelativePath.LastIndexOf('/');

      string result = rootRelativePath.Insert(index, "/v-" + date.Ticks);

index = result.LastIndexOf('.');
string switch = ConfigurationManager.Appsettings["StaticSwitch"]; //debug or min
result = result.Insert(index, "." + switch);

      HttpRuntime.Cache.Insert(rootRelativePath, result, new CacheDependency(absolute));
    }

Alex Bee
Alex Bee Russia
1/10/2013 1:08:48 AM #

Mads, I guess there should be some handler implemented which will resolve this '/content/v-634933238684083941/site.css' path. Is that right? Or I'm missing something...

Stefan Kip
Stefan Kip Netherlands
1/10/2013 2:16:40 AM #

I'm having the same question.

Fatih Şahin
Fatih Şahin Turkey
1/10/2013 6:38:52 AM #

This is an alternative to implementing a handler

you can use the querystring like

site.css?version=v-634933238684083941

Alex Bee
Alex Bee Russia
1/10/2013 6:41:17 AM #

I know that one. But I'd like to get more details on what Mads is doing.

magellin
magellin United States
1/10/2013 6:59:22 AM #

Me too!

Mads Kristensen
Mads Kristensen United States
1/10/2013 8:04:34 AM #

Of course!! How could I forget the last part...?

Thanks for reminding me, it is now added

melih g&#252;m&#252;ş&#231;ay
melih gümüşçay Turkey
1/10/2013 1:32:47 AM #

Yes this is what i thought about, too...Or thre should be some kind of iis redirect i guess

Eduardo
Eduardo Argentina
1/10/2013 5:16:28 AM #

You can use <link rel="stylesheet" href="/content/site.css?v-634933238684083941" /> if you want to avoid having a IIS redirect

Mads Kristensen
Mads Kristensen United States
1/10/2013 8:08:16 AM #

Yes, you can do that, but it's not considered a good practice to have URL parameters on static resources. Google Page Speed won't give you maximum points if you do that either.

Johan Sk&#246;ldekrans
Johan Sköldekrans Sweden
1/10/2013 6:58:22 AM #

Could this have something to do if you use IIS Express or the built in web server in VS?

Mads Kristensen
Mads Kristensen United States
1/10/2013 8:09:22 AM #

This works on both full IIS and IIS Express

Johan Sk&#246;ldekrans
Johan Sköldekrans Sweden
1/10/2013 8:21:11 AM #

Still cant get it to work. I get the rewrite tag underlined stating that it is not valid in system.webServer. I'm using VS2012 but when I google it seems to be an old problem solved in VS2010. Do I need to update the intellisense so it compiles correctly or is there something else needed in web.config?

Mads Kristensen
Mads Kristensen United States
1/10/2013 8:28:20 AM #

There is no Intellisense for it, so VS is expected to invalidate the <rewrite> element. Just ignore the validation warning. You need to run the AppPool in Integrated Pipeline mode for the <system.webServer> to take effect.

Kenneth Scott
Kenneth Scott United States
1/10/2013 9:35:49 AM #

Don't you also need to install the Microsoft URL Rewrite Module 2.0 for IIS 7 ?

Mads Kristensen
Mads Kristensen United States
1/10/2013 9:43:28 AM #

Yes, potentially. However, most hosters already have it installed and I believe it's pre-installed on IIS 8.

Johan Sk&#246;ldekrans
Johan Sköldekrans Sweden
1/11/2013 2:29:35 AM #

Doesnt seem to work with the devserver in VS, only with IIS Express.

Carsten Petersen
Carsten Petersen Denmark
1/10/2013 9:37:53 AM #

Is it possible to do something similar with MVC4 bundling / minification ... instead of the version querystring?

Mads Kristensen
Mads Kristensen
1/10/2013 9:44:48 AM #

I just asked the bundling team and they don't have a way to change that behavior for now. It's on their backlog. You can write your own Fingerprint class that works with the BundleTable to do the same thing though.

Carsten Petersen
Carsten Petersen Denmark
1/10/2013 9:53:52 AM #

Ok, thanks for asking =)

Carsten Petersen
Carsten Petersen Denmark
1/10/2013 10:20:46 AM #

Made a extension method, which converts the URL ... if anyone else might be interested ...

public static class HtmlHelper
{
  private readonly static Regex re_Version = new Regex(@"(\?v=([^$]+)$)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
  private readonly static Regex re_LastFolder = new Regex(@"(/[^/$]+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled);

  public static IHtmlString ToTag(this IHtmlString htmlString, int cacheDuration = 10)
  {
    string rootRelativePath = htmlString.ToHtmlString();
    if (HttpRuntime.Cache[rootRelativePath] == null)
    {
      var result = rootRelativePath;
      if (re_Version.IsMatch(result))
      {
        var versionString = re_Version.Match(result).Groups[2].Value;
        result = re_Version.Replace(result, "");
        if (re_LastFolder.IsMatch(result))
        {
          var lastFolderSegment = re_LastFolder.Match(result).Groups[1].Value;
          result = string.Concat(re_LastFolder.Replace(result, ""), "/v-", versionString, lastFolderSegment);
        }
      }
      HttpRuntime.Cache.Insert(rootRelativePath, result, null, DateTime.Now.AddMinutes(cacheDuration), new TimeSpan(0));
    }
    return MvcHtmlString.Create(HttpRuntime.Cache[rootRelativePath] as string);
  }
}

The web.config rewrite rule shuold be modified to ...

<rewrite>
      <rules>
        <rule name="fingerprint" stopProcessing="false">
          <match url="([\S]+)(/v-[^/]+/)([\S]+)" ignoreCase="true" negate="false" />
          <action type="Rewrite" url="{R:1}/{R:3}" />
        </rule>
      </rules>
    </rewrite>

If the razor views (eg. _Layout.cshtml) it can be included as ...

<script src="@Scripts.Url("~/bundles/jquery").ToTag()" type="text/javascript"></script>

Kenneth Scott
Kenneth Scott United States
1/10/2013 12:39:03 PM #

I'm curious...  

Instead of globally adding the staticContent/clientCache configuration in the web.config, if you wanted to only perm cache these particular resources, could you modify that rewrite rule somehow to also add a Cache-Control response header set to like "public,max-age=2147472000"?

Kenneth Scott
Kenneth Scott United States
1/10/2013 2:18:48 PM #

I tried using the URL in the outbound rule, but by then it's already been rewritten.  I had to save off the original request path in a server variable and use that in the outbound rule.

This is what i came up with.  It seems to work but the drawback is you have to remember to allow the server variable (X_REQUESTED_URL_PATH / whatever you want to call it) in IIS/applicationHost.config.

    <rewrite>
      <rules>
        <rule name="fingerprint">  
          <match url="([\S]+)(/v-[0-9]+/)([\S]+)" />
          <action type="Rewrite" url="{R:1}/{R:3}" />
          <serverVariables>
            <set name="X_REQUESTED_URL_PATH" value="{R:0}" />  
          </serverVariables>
        </rule>
      </rules>
      <outboundRules>              
        <rule name="fingerprint cache header">  
          <match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
          <conditions>
            <add input="{X_REQUESTED_URL_PATH}" pattern="([\S]+)(/v-[0-9]+/)([\S]+)" />
          </conditions>
          <action type="Rewrite" value="public,max-age=2147472000" />
        </rule>
      </outboundRules>
    </rewrite>

But this way you don't have to globally enable the staticContent/clientCache configuration.

Thoughts?

Mads Kristensen
Mads Kristensen United States
1/13/2013 11:16:36 PM #

You can add "public" to the cache-control header like so, but it would be for all static resources:

<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365:00:00" cacheControlCustom="public" />

Joe Nobody
Joe Nobody United States
1/10/2013 3:09:26 PM #

What if you use this on stylesheet A that @import's another stylesheet B?   If you make changes to stylesheet B - they'll never be picked up because only A is fingerprinted, right?

adam
adam United Kingdom
1/11/2013 4:54:26 AM #

Is there a reason to not use etags? http://en.wikipedia.org/wiki/HTTP_ETag , www.dotnetscraps.com/.../...d-IIS-demystified.aspx

Mads Kristensen
Mads Kristensen
1/13/2013 11:18:48 PM #

No, you can use ETags. That's not a problem unless you run in a web farm and you are using the default ETag generation performed automatically by IIS. And even then, it's not that big a problem

Mads Kristensen
Mads Kristensen
1/13/2013 11:19:26 PM #

Here's how to remove ETags if you want to just use the Last-Modified header instead: mark.mymonster.nl/.../improve-the-yslow-score-remove-the-etags

Martin H. Normark
Martin H. Normark Denmark
1/15/2013 12:40:51 AM #

I always use the build number assigned by the build server for cache busting.

In TeamCity it is really simple to peek into AssemblyInfo.cs and change the revision part of the version to the build number. There's a built-in feature that can do just that: confluence.jetbrains.net/.../AssemblyInfo+Patcher

Then I have a Razor HTML Helper that adds the Assembly's revision number to the URL:

public static class UrlHelperExtensions
{
  private static int _revisionNumber;

  public static string ContentVersioned(this UrlHelper urlHelper, string contentPath)
  {
    string url = urlHelper.Content(contentPath);
    int revisionNumber = GetRevisionNumber();

    return String.Format("{0}?v={1}", url, revisionNumber);
  }

  public static int GetRevisionNumber()
  {
    if (_revisionNumber == 0)
    {
      Version v = Assembly.GetExecutingAssembly().GetName().Version;

      _revisionNumber = v.Revision;
    }

    return _revisionNumber;
  }
}

And in my Razor layout page, I include scripts like this:

<script type="text/javascript" src="@Url.ContentVersioned("/Scripts/libs/backbone-min.js")"></script>

Scott Kuhl
Scott Kuhl United States
1/15/2013 7:58:34 AM #

Any advice on how to handle images referenced inside a stylesheet?

Ex: background: url('/Images/background.jpg');

Right now we add a version number to the name, but it doesn't seem like the best approach.

background: url('/Images/background-1.jpg');

Ole Marius L&#248;set
Ole Marius Løset Norway
1/15/2013 10:43:59 PM #

Will GetLastWriteTime work even when the file goes trough a VCS, or when in different time zones/different server times?

ProHOST
ProHOST Vietnam
1/27/2013 8:31:15 PM #

In my web.config file, as default is:

<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365:00:00" cacheControlCustom="public" />

But when check header

http://prohost.vn/
Date: Mon, 28 Jan 2013 04:23:59 GMT
Content-Encoding: deflate
Server: Microsoft-IIS/6.0
X-Powered-By: ASP.NET
Content-Type: text/html; charset=utf-8
Content-Script-Type: text/javascript
Cache-Control: private
Content-Style-Type: text/css
Content-Length: 12943

200 OK

Can you help me!

Tom Pietrosanti
Tom Pietrosanti United States
2/5/2013 7:30:49 AM #

Might be worth noting that if you have any relative URLs in your stylesheets, you'll need to add an extra '../' to them, or convert them to absolute URLs.

Pingbacks and trackbacks (1)+

Comments are closed

About the author

Mads Kristensen

Mads Kristensen
Program Manager at the Microsoft Web Platform team and founder of BlogEngine.NET.

More...

Month List

Disclaimer

The opinions expressed herein are my own personal opinions and do not represent my employer’s view in any way.