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: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.

Comments

Murilo

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

Murilo

Mads Kristensen

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.

Mads Kristensen

vanhauto

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)); }

vanhauto

Alex Bee

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...

Alex Bee

melih g&#252;m&#252;ş&#231;ay

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

melih g&#252;m&#252;ş&#231;ay

Eduardo

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

Eduardo

Fatih Şahin

This is an alternative to implementing a handler you can use the querystring like site.css?version=v-634933238684083941

Fatih Şahin

Alex Bee

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

Alex Bee

Johan Sk&#246;ldekrans

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

Johan Sk&#246;ldekrans

Mads Kristensen

Of course!! How could I forget the last part...? Thanks for reminding me, it is now added

Mads Kristensen

Mads Kristensen

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.

Mads Kristensen

Johan Sk&#246;ldekrans

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?

Johan Sk&#246;ldekrans

Mads Kristensen

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

Mads Kristensen

techblog.ginktage.com

Pingback from techblog.ginktage.com Interesting .NET Links &#8211; January 10 , 2013 | TechBlog

techblog.ginktage.com

Kenneth Scott

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

Kenneth Scott

Carsten Petersen

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

Carsten Petersen

Mads Kristensen

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

Mads Kristensen

Mads Kristensen

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.

Mads Kristensen

Carsten Petersen

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 ... &lt;rewrite&gt; &lt;rules&gt; &lt;rule name="fingerprint" stopProcessing="false"&gt; &lt;match url="([\S]+)[b](/v-[^/]+/)[/b]([\S]+)" ignoreCase="true" negate="false" /&gt; &lt;action type="Rewrite" url="{R:1}/{R:3}" /&gt; &lt;/rule&gt; &lt;/rules&gt; &lt;/rewrite&gt; If the razor views (eg. _Layout.cshtml) it can be included as ... &lt;script src="@Scripts.Url("~/bundles/jquery").ToTag()" type="text/javascript"&gt;&lt;/script&gt;

Carsten Petersen

Kenneth Scott

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

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. &lt;rewrite&gt; &lt;rules&gt; &lt;rule name="fingerprint"&gt; &lt;match url="([\S]+)(/v-[0-9]+/)([\S]+)" /&gt; &lt;action type="Rewrite" url="{R:1}/{R:3}" /&gt; &lt;serverVariables&gt; &lt;set name="X_REQUESTED_URL_PATH" value="{R:0}" /&gt; &lt;/serverVariables&gt; &lt;/rule&gt; &lt;/rules&gt; &lt;outboundRules&gt; &lt;rule name="fingerprint cache header"&gt; &lt;match serverVariable="RESPONSE_Cache-Control" pattern=".*" /&gt; &lt;conditions&gt; &lt;add input="{X_REQUESTED_URL_PATH}" pattern="([\S]+)(/v-[0-9]+/)([\S]+)" /&gt; &lt;/conditions&gt; &lt;action type="Rewrite" value="public,max-age=2147472000" /&gt; &lt;/rule&gt; &lt;/outboundRules&gt; &lt;/rewrite&gt; But this way you don't have to globally enable the staticContent/clientCache configuration. Thoughts?

Kenneth Scott

Joe Nobody

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?

Joe Nobody

Johan Sk&#246;ldekrans

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

Johan Sk&#246;ldekrans

adam

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

adam

Mads Kristensen

You can add "public" to the cache-control header like so, but it would be for all static resources: &lt;clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365:00:00" cacheControlCustom="public" /&gt;

Mads Kristensen

Mads Kristensen

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

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

Mads Kristensen

Martin H. Normark

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: http://confluence.jetbrains.net/display/TCD7/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: &lt;script type="text/javascript" src="@Url.ContentVersioned("/Scripts/libs/backbone-min.js")"&gt;&lt;/script&gt;

Martin H. Normark

Scott Kuhl

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');

Scott Kuhl

Ole Marius L&#248;set

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

Ole Marius L&#248;set

ProHOST

In my web.config file, as default is: [b]&lt;clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365:00:00" cacheControlCustom="public" /&gt;[/b] But when check header [b]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 [/b] Can you help me!

ProHOST

Tom Pietrosanti

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.

Tom Pietrosanti

Mohamed Kadi

Or you can add a rewrite rule.. For my own project, this is what it looks like.. &lt;rule name="cssImages"&gt; &lt;match url="(stylesheets/images/)([\S]+)" /&gt; &lt;action type="Rewrite" url="images/{R:2}" /&gt; &lt;/rule&gt;

Mohamed Kadi

Joe Wilson

Combining JavaScript bundling, minification, cache busting, and easier debugging Combining JavaScript bundling, minification, cache busting, and easier debugging

Joe Wilson

com-lab.biz

Pingback from com-lab.biz Caching images with asp/http | user55

com-lab.biz

shamoon14

Well, you can use Long Path Tool for such issues, it works good.

shamoon14

Tom Navarra

Mads, I believe you are setting the maxage to 365 hours and not days above. Shouldn't cacheControlMaxAge="365:00:00" be cacheControlMaxAge="365.00:00:00"?

Tom Navarra

Mads Kristensen

Tom, thanks for the heads up. I've fixed it in the post

Mads Kristensen

Werner Strydom

Interesting technique. However, it isn't bullet proof, especially with a very large web farm which has to serve traffic 24x7. Care must be taken that requests for "/content/v-634933238684083941/site.css" is truly served by a server with that content and not another server. In addition, there is a risk of a DoS attack. All I have to do as a hacker is to change the fingerprint for every request. For smaller sites, low bandwidth sites this may not be a problem, but for critical systems it is. To mitigate risk, its better to upload artifacts to a cookie-less domain hosted by your CDN. If the fingerprint is modified, a 404 will be returned without any traffic making it to the origin servers.

Werner Strydom

Mads Kristensen

@Werner, changing the fingerprint doesn't affect the rewrite rule and the CSS file will still be served. A 404 response will not occur even if the fingerprint changes.

Mads Kristensen

Mark Rendle

I use a similar cache-busting technique, except rather than the timestamp, I use the assembly version number of the web application project (and make sure my vendor static files always include their version number). Quick note to the person who suggested query string parameters: that won't necessarily work if you're serving your static content through a CDN. Munging version info into the path itself forces the CDN to do a fetch-through if it doesn't have that precise version of the file.

Mark Rendle

Livingston

Doesn't the asp.net bundling and minification support do this?

Livingston

Comments are closed