46 Comments

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

Comment by 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

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

Comment by 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

Comment by 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

Comment by 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

Comment by Eduardo

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

Comment by 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

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

Comment by 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

Comment by Mads Kristensen

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.

Comment by Carsten Petersen

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

Carsten Petersen

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

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

<rewrite>
<rules>
<rule name="fingerprint" stopProcessing="false">
<match url="([\S]+)[b](/v-[^/]+/)[/b]([\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>

Carsten Petersen

Comment by 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"?

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

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

Comment by 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

Comment by Mads Kristensen

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" />

Comment by 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

Comment by 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: 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>

Comment by 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

Comment by 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

Comment by ProHOST

In my web.config file, as default is:

[b]<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365:00:00" cacheControlCustom="public" />[/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!

Comment by 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

Comment by Mohamed Kadi

Or you can add a rewrite rule.. For my own project, this is what it looks like..

<rule name="cssImages">
<match url="(stylesheets/images/)([\S]+)" />
<action type="Rewrite" url="images/{R:2}" />
</rule>

Mohamed Kadi

Comment by Joe Wilson

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

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

Comment by 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

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

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

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