hackification

Homepage

...rediscover the joy of coding

Controlling Browser Content Expiration in ASP.NET

Correctly controlling content expiration is a tricky thing. You need to balance two goals: getting the browser to cache as much as possible to reduce page load times and bandwidth, versus not showing the user stale content. In this article I'll cover a simple technique (with code) to solve this issue in ASP.NET for stylesheets and scripts.

Simplifying things a little, every file you serve to a browser comes with an expiration date. This can be an immediate expiration (as you'd set for dynamic pages), or sometime in the future. Generally, if the browser needs a particular file again, and it hasn't expired, it will try to use a cached copy, and not ask your server for it again.

Let's consider stylesheets and scripts. With standard content expiration, we have three choices: None of these options are ideal, but there is a better way.

Content Should Expire Only When It Changes

If we had the time, we could version every file. We'd start with 'stylesheet-1.css', and when it changed, rename it to 'stylesheet-2.css', and fix up every single reference in the website to the new name. If the content expiration for these versioned files was set to 'never' (or a long time in the future), we would have solved the problem.

Doing that by hand would be a real pain - hence we need an automated way. An alternative to integer versioning is a hash - whenever the content changes, the hash will too - and to save the trouble of renaming files, we can easily add the hash as a query string. Of course, you'll need to make sure that the content expiry of these hashed files is marked as 'never' - either via IIS, or via some form of static-file handler.

The following code shows how to automatically add these hashes. The pattern I'm going to use is a UserControl, into which we place script or stylesheet references, for example:
<%@ Register TagPrefix="co" TagName="CacheControl" Src="~/.../CacheControl.ascx" %>

<co:CacheControl runat="server"> <link rel="Stylesheet" href="Path/To/Reset.css" /> <link rel="Stylesheet" href="Path/To/Base.css" /> </co:CacheControl>
The output will be something like:
<link rel="Stylesheet" href="Path/To/Reset.css?hash=567164ca-96c5-6305-a697-a12be0f98499" />
<link rel="Stylesheet" href="Path/To/Base.css?hash=6a11adca-1593-a529-a574-735cb308a9a7" />
Source Code

To support this pattern of usage, we'll need to add a couple of attributes:
[ParseChildren( false )]
[PersistChildren( true )]
public partial class CacheControl : System.Web.UI.UserControl
{
  ...
}
Now on to the main code. Basically, the UserControl needs to get the concatenated text of the controls inside itself, transform that text (adding the hashes), then replace its own content:
protected void Page_Load( object sender, EventArgs e )
{
  var content = string.Empty;

foreach( Control control in Controls ) { var literal = control as LiteralControl; var generic = control as HtmlGenericControl;

if( literal != null ) { content += literal.Text; } else if( generic != null ) { var text = "<" + generic.TagName + " ";

foreach( string key in generic.Attributes.Keys ) { text += key + "=\"" + generic.Attributes[key] + "\" "; }

text += "/>";

content += text; } else { throw new InvalidOperationException(); } }

content = content.Trim();

Controls.Clear();

lock( _lock ) { string output;

if( !_mapInputToOutput.TryGetValue( content, out output ) ) { output = ApplyCacheControl( Context, content );

#if !DEBUG _mapInputToOutput.Add( content, output ); #endif }

Controls.Add( new LiteralControl { Text = output } ); } } private static object _lock = new object(); private static Dictionary<string, string> _mapInputToOutput = new Dictionary<string, string>();
(Since the same conversions will be applied over and over, I'm storing the results. I'm also assuming that whenever the file content changes, the web server will be re-started).

The next part parses the content as XML, iterates over the sub-items, determines which attribute contains the path, and writes back the transformed filename:
private string ApplyCacheControl( HttpContext context, string input )
{
  var xdoc = XDocument.Parse( "<root>" + input + "</root>" );
  var sb = new StringBuilder();

sb.Append( Environment.NewLine );

foreach( var child in xdoc.Root.Elements() ) { var linkAttrName = GetLinkAttribute( child ); var sourceFile = child.Attribute( linkAttrName ).Value;

var hash = GetFileHash( sourceFile );

child.Attribute( linkAttrName ).Value = TransformFilename( context, sourceFile, hash );

var childText = child.ToString();

if( childText.StartsWith( "<script" ) && childText.EndsWith( "/>" ) ) { childText = childText.Substring( 0, childText.Length - 2 ).Trim() + "></script>"; }

sb.Append( childText ); sb.Append( Environment.NewLine ); }

return sb.ToString(); }

private static string GetLinkAttribute( XElement element ) { switch( element.Name.LocalName ) { case "link": return "href"; case "script": return "src"; default: throw new InvalidOperationException(); } }
Calculating the hash of the file is pretty easy:
private Guid GetFileHash( string filename )
{
  var baseUri = Code.Site.Path.GetBaseUri( Request );

if( filename.StartsWith( baseUri.AbsoluteUri ) ) { filename = filename.Substring( baseUri.AbsoluteUri.Length ); }

filename = filename.Replace( '/', '\\' );

var localPath = Path.Combine( Request.PhysicalApplicationPath, filename ); Guid hash;

if( !_mapFileToHash.TryGetValue( localPath, out hash ) ) { var ms = new MemoryStream();

using( var fs = new FileStream( localPath, FileMode.Open, FileAccess.Read, FileShare.Read ) ) { Utility.StreamCopy( fs, ms ); }

var myMD5 = new System.Security.Cryptography.MD5CryptoServiceProvider(); var hashedTextInBytes = myMD5.ComputeHash( ms.ToArray() );

hash = new Guid( hashedTextInBytes );

#if !DEBUG _mapFileToHash.Add( localPath, hash ); #endif }

return hash; } private static Dictionary<string, Guid> _mapFileToHash = new Dictionary<string, Guid>();
(Again, cached for speed).

Here's my utility stream copy routine:
public static class Utility
{
  public static void StreamCopy( Stream from, Stream to )
  {
    if( from == to )
    {
      return;
    }

var buffer = new byte[4096];

from.Seek( 0, SeekOrigin.Begin );

while( true ) { var done = from.Read( buffer, 0, 4096 );

if( done <= 0 ) { return; }

to.Write( buffer, 0, done ); } } }
Finally, the function to transform the filename:
private string TransformFilename( HttpContext context, string filename, Guid hash )
{
  filename += "?hash=" + hash.ToString();

return filename; }
You might be wondering why I passed the HttpContext all the way through to this function, only to not use it. Well in my code I do use it - I further transform the filename to pass it onto a handler for static files, that sets the expiry to 'never'.

[Updated 01 Feb 2009 - I've added the debug conditionals to make debugging a little easier - now in debug you can change your scripts and stylesheets on the fly and the browser will get the latest version.]