Code, Code, Revolution!
Letting browsers cache files is a great way to improve your page load speed but also to reduce your bandwidth usage and ultimately save some money. It’s a win-win situation! Yslow recomends that files are cached at least 2 days, Googles Page Speed recommend 30 days. However, letting visitors cache css and javascript files can cause problems if/when you need to update those files but wouldn’t it be awesome to add an infinite cache timeout as long as the file doesn’t change? The solution to allow long term caching and still be able to make changes is to change the name of your file(s) every time you make a change, another is to append a querystring parameter to the url making it “new”. Manual changes are always tedious and tend to fail sooner or later, so wouldn’t it be nice if versioning was totally automated?
When I’ve pondered the problem of automating versioning of page includes I’ve come up with the following two reasonable ways to do it:
Alternative one makes the most sence to me, it’s pretty easy to implement, it will have minimum performance impact and it will allow for more control than having a build script update the code.
My solution is based on adding a querystring parameter to the css and javascript include urls to make them version unique. The querystring value is the last write time (or last modified time if you will) as ticks. The key component in my solution is the “IncludesManager” which process, cache and monitor included static files. This is what happens:

The manager has an internal cache so that a url is only processed once. Because a file system watcher is monitoring the file the versioned URL it will be updated immediately if the physical file is changed. This will allow you to set indefinately long cache timeouts for your includes but changes will still be enforced immediately!
To make it easy to include versioned static files I’ve created two web controls. One for including CSS-files and one for JavaScript-files. Either include the controls through web.config in Pages->Controls section
<add tagPrefix="Sallarp" namespace="Blog.Sallarp.Com.PageIncludes.WebControls" assembly="Blog.Sallarp.Com.PageIncludes"/>or include the controls in your aspx/ascx file. Including CSS and JavaScript is super simple:
<Sallarp:StylesheetInclude runat="server" rel="stylesheet" type="text/css" media="screen" href="~/Templates/Public/Styles/Glossy/Styles.css" /> <Sallarp:StylesheetInclude runat="server" rel="stylesheet" type="text/css" media="print" href="~/Templates/Public/Styles/print.css" /> <Sallarp:JavascriptInclude runat="server" src="~/Templates/Public/Styles/test.js" defer="defer"></Sallarp:JavascriptInclude>
The resulting HTML will look like so:
<link rel="stylesheet" type="text/css" media="screen" href="/Templates/Public/Styles/Glossy/Styles.css?v=633970887680000000" /> <link rel="stylesheet" type="text/css" media="print" href="/Templates/Public/Styles/print.css?v=634047013810147524" /> <script defer="defer" type="text/javascript" src="/Templates/Public/Styles/test.js?v=634047085480876357"></script>
using System; using System.Collections.Generic; using System.Web; using System.IO; namespace Blog.Sallarp.Com.PageIncludes { public sealed class IncludesManager { // Singleton! private static readonly IncludesManager _instance = new IncludesManager(); // Dictionaries use to cache urls and store filesystem watchers. Dictionary<string, string> _includeFiles = new Dictionary<string, string>(); Dictionary<FileSystemWatcher, string> _watchers = new Dictionary<FileSystemWatcher, string>(); private IncludesManager() { } /// <summary> /// Takes a url and appends a version querystring based on last write time /// of the physical file on disk. If the file doesn't exist the original /// url will just be passed back unchanged. /// </summary> /// <param name="includeUrl">Url to process</param> /// <returns></returns> public string GetVersionedIncludeUrl(string includeUrl) { // Check our cache if the url has already been processed if (!_includeFiles.ContainsKey(includeUrl)) { // Map the url to physical file string physicalFile = HttpContext.Current.Server.MapPath(includeUrl); // Make sure the physical file actually exist. if (File.Exists(physicalFile)) { // Read the last write time, we use the ticks to make the version unique. string writeTime = File.GetLastWriteTime(physicalFile).Ticks.ToString(); // Create a file system watcher for the file. The watcher will detect changes made to the // file so we know to updated the url. FileSystemWatcher watcher = CreateWatcher(physicalFile); _watchers.Add(watcher, includeUrl); // Append a querystring value to make the url unique for the current version. char querySeparator = includeUrl.IndexOf('?') > 0 ? '&' : '?'; _includeFiles.Add(includeUrl, string.Format("{0}{1}v={2}", includeUrl, querySeparator, writeTime)); } } // Check if the cache contains a versioned url for the passed url. if (_includeFiles.ContainsKey(includeUrl)) { includeUrl = _includeFiles[includeUrl]; } return includeUrl; } /// <summary> /// Creates a FileSystemWatcher for a given file path. The method expects that the /// file path actually exist. /// </summary> /// <param name="physicalPath">Absolute path to file on disk</param> /// <returns></returns> private FileSystemWatcher CreateWatcher(string physicalPath) { string path = Path.GetDirectoryName(physicalPath); string fileName = Path.GetFileName(physicalPath); FileSystemWatcher watcher = new FileSystemWatcher(); watcher.Path = path; // We filter the watcher to only trigger changes for our specific file watcher.Filter = fileName; watcher.EnableRaisingEvents = true; watcher.IncludeSubdirectories = false; watcher.Changed += new FileSystemEventHandler(watcher_Changed); watcher.Error += new ErrorEventHandler(watcher_Error); return watcher; } #region FileSystemWatcher events /// <summary> /// Event reciever for FileSystemWatcher. If an error event is triggered we /// remove the watcher and the cached include url. By doing so, next time the /// include file is processed a new watcher will be created again. /// </summary> /// <param name="sender"></param> /// <param name="e"></param> void watcher_Error(object sender, ErrorEventArgs e) { if (_watchers.ContainsKey(sender as FileSystemWatcher)) { // Remove the cached include-file url _includeFiles.Remove(_watchers[sender as FileSystemWatcher]); // Remove the watcher (sender as FileSystemWatcher).Dispose(); _watchers.Remove(sender as FileSystemWatcher); } } /// <summary> /// Event reciever for FileSystemWatcher. If a file we're watching is changed in some way /// this event will be fired. If the file is updated we grab the new last write time and update /// the version url. If the file is deleted we remove the cached url and the watcher for that file. /// </summary> /// <param name="sender"></param> /// <param name="e"></param> void watcher_Changed(object sender, FileSystemEventArgs e) { if (e.ChangeType == WatcherChangeTypes.Changed) { // Grab the original include url for this watcher string includeUrl = _watchers[sender as FileSystemWatcher]; // Update the versioned url for the include file if(_includeFiles.ContainsKey(includeUrl)) { char querySeparator = includeUrl.IndexOf('?') > 0 ? '&' : '?'; _includeFiles[includeUrl] = string.Format("{0}{1}v={2}", includeUrl, querySeparator, File.GetLastWriteTime(e.FullPath).Ticks.ToString()); } } else if(e.ChangeType == WatcherChangeTypes.Deleted) { // Remove the cached include-file url _includeFiles.Remove(_watchers[sender as FileSystemWatcher]); // If the file is deleted we remove the watcher (sender as FileSystemWatcher).Dispose(); _watchers.Remove(sender as FileSystemWatcher); } } #endregion public static IncludesManager Instance { get { return _instance; } } } }
using System; using System.Collections.Generic; using System.Web; using System.Web.UI; using System.Web.UI.HtmlControls; namespace Blog.Sallarp.Com.PageIncludes.WebControls { public class JavascriptInclude : HtmlContainerControl { public JavascriptInclude() : base("script") { } protected override void CreateChildControls() { if (!string.IsNullOrEmpty(src)) { this.Attributes.Add("type", "text/javascript"); string includeUrl = IncludesManager.Instance.GetVersionedIncludeUrl(src); // If first char is a tilde we need to resolve the url if (includeUrl[0].Equals('~')) { includeUrl = ResolveUrl(includeUrl); } this.Attributes.Add("src", includeUrl); } base.CreateChildControls(); } public string src { get; set; } } public class StylesheetInclude : HtmlContainerControl { public StylesheetInclude() : base("link") { rel = "stylesheet"; type = "text/css"; media = "screen"; } protected override void CreateChildControls() { if (!string.IsNullOrEmpty(href)) { this.Attributes.Add("rel", rel); this.Attributes.Add("type", type); this.Attributes.Add("media", media); string includeUrl = IncludesManager.Instance.GetVersionedIncludeUrl(href); // If first char is a tilde we need to resolve the url if (includeUrl[0].Equals('~')) { includeUrl = ResolveUrl(includeUrl); } this.Attributes.Add("href", includeUrl); } } public string rel { get; set; } public string type { get; set; } public string media { get; set; } public string href { get; set; } } }
This download contains the code in VS2008 format, .NET 2.0 compilation. Enjoy!
With this blog I try to provide useful tips and solutions for programming .NET, Objective-C and more. My name is Björn Sållarp, and I love writing code.
Kel
March 31st, 2010 at 10:53 pm
Hi – thank you…this is working well for me… I am going to modify to also minify any included script to a .min file and then send that one.
Björn Sållarp
April 1st, 2010 at 7:21 am
I use Yahoos minifier library, it’s available as a dll from codeplex. To improve your performance even further consider using a post build task that not just minify but also combine all your css and JavaScript files. Only vaild if you have multiple files of course. Yahoo’s library does an awesome job at this.
// Björn
Alain Bourdiaudhy
May 25th, 2010 at 4:19 pm
Thanks, this works just as advertised !