Cache busting in ASP.NET
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
So, what is the reason of "Last modified"? If I set 365 days for caching, the browser doesn`t check the last modified date?
MuriloCorrect, 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 KristensenThat'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)); }
vanhautoMads, 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 BeeYes this is what i thought about, too...Or thre should be some kind of iis redirect i guess
melih gümüşçayI'm having the same question.
Stefan KipYou can use <link rel="stylesheet" href="/content/site.css?v-634933238684083941" /> if you want to avoid having a IIS redirect
EduardoThis is an alternative to implementing a handler you can use the querystring like site.css?version=v-634933238684083941
Fatih ŞahinI know that one. But I'd like to get more details on what Mads is doing.
Alex BeeCould this have something to do if you use IIS Express or the built in web server in VS?
Johan SköldekransMe too!
magellinOf course!! How could I forget the last part...? Thanks for reminding me, it is now added
Mads KristensenYes, 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 KristensenThis works on both full IIS and IIS Express
Mads KristensenStill 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öldekransThere 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.
Mads KristensenPingback from techblog.ginktage.com Interesting .NET Links – January 10 , 2013 | TechBlog
techblog.ginktage.comDon't you also need to install the Microsoft URL Rewrite Module 2.0 for IIS 7 ?
Kenneth ScottIs it possible to do something similar with MVC4 bundling / minification ... instead of the version querystring?
Carsten PetersenYes, potentially. However, most hosters already have it installed and I believe it's pre-installed on IIS 8.
Mads KristensenI 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 KristensenOk, thanks for asking =)
Carsten PetersenMade 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 PetersenI'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 ScottI 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?
Kenneth ScottWhat 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 NobodyDoesnt seem to work with the devserver in VS, only with IIS Express.
Johan SköldekransIs 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
adamYou 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" />
Mads KristensenNo, 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 KristensenHere'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 KristensenI 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: <script type="text/javascript" src="@Url.ContentVersioned("/Scripts/libs/backbone-min.js")"></script>
Martin H. NormarkAny 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 KuhlWill GetLastWriteTime work even when the file goes trough a VCS, or when in different time zones/different server times?
Ole Marius LøsetIn 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!
ProHOSTMight 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 PietrosantiOr 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 KadiCombining JavaScript bundling, minification, cache busting, and easier debugging Combining JavaScript bundling, minification, cache busting, and easier debugging
Joe WilsonPingback from com-lab.biz Caching images with asp/http | user55
com-lab.bizWell, you can use Long Path Tool for such issues, it works good.
shamoon14Mads, 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 NavarraTom, thanks for the heads up. I've fixed it in the post
Mads KristensenInteresting 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@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 KristensenI 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 RendleDoesn't the asp.net bundling and minification support do this?
LivingstonComments are closed