Site Meter
 
 

Partial Output Caching in ASP.NET MVC

The ASP.NET platform provides two major caching facilities:

  • Data caching, which lets you cache arbitrary .NET objects in an HttpContext’s Cache collection. You can specify expiration rules and cache entry priorities.
  • Output caching, which tells pages and user controls to cache their own rendered output and re-use it on subsequent requests. This is designed to sit neatly in WebForms’ system of server control hierarchies.

Data caching continues to work perfectly well in ASP.NET MVC, because it’s just about getting objects in and out of a collection, and isn’t specific to any particular UI technology.

If only output caching was so simple! ASP.NET’s output caching facility is deeply stuck in WebForms thinking, which makes it problematic in ASP.NET MVC. You could try to use ASP.NET output caching with ASP.NET MVC, but then you’d have the following issues:

  • No usable support for partial caching. ASP.NET output caching can cache complete responses or individual server controls, but hang on: we’re not using server controls in ASP.NET MVC, so all that’s left is complete response caching.
  • Bypasses authorization and other filters. ASP.NET output caching runs very early in the request-processing pipeline (see HttpApplication’s ResolveRequestCache event), long before MVC comes in with its controllers, actions, and filters. It can’t behave like an action filter is supposed to. See update below.

Unfortunately, the [OutputCache] filter that ships with ASP.NET MVC is merely a thin wrapper around ASP.NET output caching, so it has exactly those problems. The MVC team have explained that they’re aware of the issues, but it’s very difficult to make ASP.NET output caching fit into MVC’s design, and they are focusing on other things first. And personally I’m happy with that: I’d rather see a finished 1.0 RTM release this year than fuss about output caching.

Update: Since the Beta release, the [Authorize] filter now does some clever trickery to co-operate with ASP.NET output caching. Specifically, it registers a delegate using HttpCachePolicy.AddValidationCallback(), so that it can intercept future cache hits and tell ASP.NET output caching not to use the cache when [Authorize] would reject the request. This solves the problem of ASP.NET output caching bypassing the [Authorize] filter. If you’re going to write your own authorization filter, be sure to derive it from AuthorizeAttribute so you can inherit this useful behaviour.

Note that this doesn’t stop ASP.NET output caching from bypassing any of your other action filters, and it doesn’t add any support for partial caching. If that’s a problem for you then consider using [ActionOutputCache] (below) instead.

Fixing it ourselves

One reason why I don’t mind [OutputCache]‘s limitations so much is that ASP.NET MVC is extremely extensible, and without too much trouble we can replace the output caching system with something new and more suitable.

We can quite easily create a new caching filter that captures actions’ output and uses ASP.NET’s data caching facility to store it for next time. This filter will fit properly into the MVC pipeline, not strangely bypassing authorization or other earlier filters (it will run at the right time in whatever ordered set of filters you’ve using). And if you’re using something like the PartialRequest system for widgets that I described yesterday, it will naturally let you cache PartialRequests’ output separately from the actions that host them, which is also known as partial output caching.

I must first acknowledge that the following code is very similar to the custom output caching filter that Maarten Balliauw presented way back in June. The reason I think this warrants a whole new post is because the following code works better for partial caching with PartialRequest and with MVC Contrib’s subcontrollers, and because it would be good for more MVC developers to discover it. But Maarten was first to write a good blog post on this subject.

So here it is. Drop the following class somewhere in your MVC project:

public class ActionOutputCacheAttribute : ActionFilterAttribute
{
    // This hack is optional; I'll explain it later in the blog post
    private static MethodInfo _switchWriterMethod = typeof(HttpResponse).GetMethod("SwitchWriter", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
 
    public ActionOutputCacheAttribute(int cacheDuration)
    {
        _cacheDuration = cacheDuration;
    }
 
    private int _cacheDuration;
    private TextWriter _originalWriter;
    private string _cacheKey;
 
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        _cacheKey = ComputeCacheKey(filterContext);
        string cachedOutput = (string)filterContext.HttpContext.Cache[_cacheKey];
        if (cachedOutput != null)
            filterContext.Result = new ContentResult { Content = cachedOutput };
        else
            _originalWriter = (TextWriter)_switchWriterMethod.Invoke(HttpContext.Current.Response, new object[] { new HtmlTextWriter(new StringWriter()) });
    }
 
    public override void OnResultExecuted(ResultExecutedContext filterContext)
    {
        if (_originalWriter != null) // Must complete the caching
        {
            HtmlTextWriter cacheWriter = (HtmlTextWriter)_switchWriterMethod.Invoke(HttpContext.Current.Response, new object[] { _originalWriter });
            string textWritten = ((StringWriter)cacheWriter.InnerWriter).ToString();
            filterContext.HttpContext.Response.Write(textWritten);
 
            filterContext.HttpContext.Cache.Add(_cacheKey, textWritten, null, DateTime.Now.AddSeconds(_cacheDuration), Cache.NoSlidingExpiration, System.Web.Caching.CacheItemPriority.Normal, null);
        }
    }
 
    private string ComputeCacheKey(ActionExecutingContext filterContext)
    {
        var keyBuilder = new StringBuilder();
        foreach (var pair in filterContext.RouteData.Values)
            keyBuilder.AppendFormat("rd{0}_{1}_", pair.Key.GetHashCode(), pair.Value.GetHashCode());
        foreach (var pair in filterContext.ActionParameters)
            keyBuilder.AppendFormat("ap{0}_{1}_", pair.Key.GetHashCode(), pair.Value.GetHashCode());
        return keyBuilder.ToString();
    }
}

Now you can use [ActionOutputCache] instead of MVC’s built-in [OutputCache]. The advantage of [ActionOutputCache] is that it’s a fully native MVC action filter, and doesn’t rely on or inherit the problems of ASP.NET’s WebForms-oriented output caching technology. So, for example, [ActionOutputCache] plays nicely with MVC’s [Authorize] filter. Update: Since the beta release, the built in [Authorize] and [OutputCache] filters now play nicely together too.

You can use [ActionOutputCache] to cache the output of any action method just like [OutputCache], but it’s perhaps most interesting when you combine it with the PartialRequests method of rendering widgets (or use it with Html.RenderAction(), which isn’t compatible with the built-in [OutputCache]). Put an [ActionOutputCache] attribute on the widget’s action method (not on the action that hosts it), then you’ll have partial page caching, as shown in the following code.

public class BlogController : Controller
{
    [ActionOutputCache(60)] // Caches for 60 seconds
    public ActionResult LatestPosts()
    {
        ViewData["currentTime"] = DateTime.Now;
        ViewData["posts"] = new[] {
            "Here's a post",
            "Here's another post. Marvellous.",
            "Programmer escapes from custody"
        };
        return View();
    }
}

image 

This is great if your widget displays some relatively static data (e.g., a list of the “most recent” things), or is an action method whose output is constant for a given set of parameters (e.g, a dynamically-built navigation menu that highlights the visitor’s current location, where the current location is one of the parameters passed to the action method), and you don’t want to recompute it on every page hit.

Support for partial caching is a major advantage of PartialRequest over the use of viewdata-populating filters and partial views to render widgets. The filter/partialview technique can never support proper output caching, because inherently it mixes the widget’s viewdata with the main page’s viewdata, and the two can’t be distinguished by the time you’re actually rendering the view. The closest you could get would be to limit yourself to data caching, but that’s more complex and not always viable anyway, such as if you’re using an IQueryable to defer a SQL query until view rendering time.

Notes

To keep the [ActionOutputCache] code short and easy to understand, and because its current behaviour is adequate for my own current project’s needs, there are a number of limitations and caveats you should know about:

  • It uses reflection to access HttpResponse’s private SwitchWriter() method. That’s how it’s able to intercept all the output piped to Response during subsequent filters and the action method being cached. It’s unfortunate that SwitchWriter() is marked private, but it is. If you don’t want to bypass the “private” access modifier this way, or if you can’t (e.g., because you’re not hosting in full-trust mode), then you can download an alternative implementation that uses a filter to capture output instead. This isn’t quite as straightforward, but some people will prefer/need it.
  • It’s hard-coded to generate cache keys that vary by all incoming action method parameters and route values, and not by anything else. You would have to modify the code if you needed the ability to vary cache entry by other context parameters (such as unrelated querystring or form values).
  • When generating cache keys, it assumes that the action method parameter types and route value types all have sensible implementations of GetHashCode(). This is fine for primitive types (strings, ints, etc.), but if you try to use it with a custom parameter types that have no proper implementation of GetHashCode(), it will pick a different cache key every time and appear not to be caching. So implement GetHashCode() properly on any such custom parameter types.
  • It doesn’t attempt to cache and replay HTTP headers, so it’s not suitable for caching action methods that issue redirections.

In other words, it works great for most straightforward widget output caching scenarios, but if you’re doing something more complex then please be prepared to dive into the code yourself! Hope this is useful to a few people.

32 Responses to Partial Output Caching in ASP.NET MVC

  1. Great post, will be using this!

  2. OutputCaching in MVC Beta does not bypass authorization filters. We fixed that bug. Try it out. :)

  3. Interesting pattern.. Thanks for sharing, Steve.

  4. Steve

    @Haacked – Thanks for letting me know! I’ve updated this blog post.

  5. yang

    I could not manage it to work. Do I have to use min. preview 4 for this to work?

  6. Correct me if I am wrong but I think you should add filterContext.Cancel = true after you retrieve the response from the cache. This way the filter runs the Action always.

    Cheers!

  7. Steve

    @Onsel – actually no, since preview 5 there isn’t a Cancel flag there any more. Simply assigning a result before the action runs causes the action not to be run.

  8. Great stuff!

    If I put this above my controller will it apply to each Action on that controller ?

  9. Steve

    > If I put this above my controller will it apply to each Action on that controller ?

    Sure will!

  10. Pingback: Sub-Controllers, PartialRequests and Separating Views From Controllers « Giraffe: Developer

  11. Steve, I used your concepts and code in the latest release of AtomSite 0.9. http://atomsite.net

    AtomSite has support for composite widgets and normal partial view widgets. Take a look at the source code and thanks for your great blog posts.

  12. Steve

    @Jarrett – nice one! I’ll be checking out your platform next time I need to make a CMS-style site.

    @Miha – great point about the content-type and encoding.

  13. Hi, very useful:) thnx.

    i wrote extension method, wich encapsulates you hack with _switchMethod:

    public static class PublicSwitchWriterOnHttpResponceExtension {
    public static readonly MethodInfo _switchWriterMethod = typeof(HttpResponse).GetMethod(“SwitchWriter”, BindingFlags.Instance | BindingFlags.NonPublic);

    public static TextWriter SwitchWriter(this HttpResponse response, TextWriter writer) {
    return (TextWriter)_switchWriterMethod.Invoke(response, new object[] { writer });
    }
    }

  14. Dan

    I like this, but, its waaay too slow for what I am doing. Which defeats the purpose. The built-in output cache is must faster for me.

  15. Pingback: Cache in MVC ActionFilterAttribute « Ray Asp.net Blog

  16. Marco

    Doesn’t work with non-default content types. Try out this:

    [ActionOutputCache(10)]
    public ActionResult Image()
    {
    return File(@”C:\picture.jpg”, “image/jpeg”);
    }

    Because it catches the response writer before response content type has been set for later usage, it later on return the image with text/html content type and corrupts output.

  17. nick caramello

    This works well if you always populate the routedata values. If you dont the compute cache key method throws an exception. I slightly modified that code to compute the cache key as

    keyBuilder.AppendFormat(“ap{0}_{1}_”, pair.Key.GetHashCode(), (pair.Value ?? string.Empty).GetHashCode());

    where the (pair.Value ?? string.Empty) is good enough for my needs, and I suspect most peoples.

    Thanks for the code.

  18. Jon

    Has anyone done any performance metrics using this approach? How expensive is it to fire up an mvc “sub-pipeline”? At the moment I’m trying to implement my own “Donut” caching mechanism to work with this. It will work by caching a list of “CacheItems” against an action that either return a string or fire off a partial. Anyone tried anything like this?

  19. Steve

    @Marco – good catch. I guess you’d need to enhance the cache so that it cached the content-type header as well as the content. Would you want to cache other output headers too, and which ones?

    @Nick – haven’t tried it myself, but that sounds good.

    @Jon – it certainly should be more performant than loading the same partial content via an Ajax request, but won’t be as performant on the first hit as not caching it at all. Whether or not you get a net benefit depends on how expensive it is to recompute the content you’re caching, so if the performance really matters to you, there’s no substitute for measuring it in your own app.

  20. NgocLuu

    Very useful, I’m using it in my projects.
    Thanks for the code.

  21. I’m using the alt implementation that does not require reflection. It does however use HttpContext.Response.Flush().

    Now the app intermittently returns “HttpException: Session state has created a session id, but cannot save it because the response was already flushed by the application.”

    Just wanted to make everyone aware that flushing the cache may cause this. Please post if you find a work-around.

    Thanks!

  22. Hush Hush papa sleeping

  23. eric sims

    i’m getting this to work. i’m running in the debugger and it’s not hitting any breakpoints on any method in the ActionOutputCacheAttribute class. any ideas?

  24. Greetings I recently finished reading through your blog as well as I’m very impressed. I truly do have a couple queries for you personally however. Think you’re thinking about doing a follow-up posting about this? Will you be planning to keep bringing up-to-date at the same time?

  25. Robert

    My problem is that I don’t want to cache partial views that are redered by an action method that is called via jQuery Ajax. The Url is /Controller/Action/ID=”some number”. Different calls to the same Url should return a different result if the underlying data corresponding to the ID value that is passed to the action method changes. However, I am not seeing the changes in subsequent calls to the same Url because the output from a previous call to the same Url is cached. For example, if the ID represents an order and an order detail item is removed, a subsequent call to the action method should show the order with one fewer item. But it does not render the updated order detail without the deleted item because the call to the action method never happens. The only way I have been able to get subsequent calls to the same Url to render updated results is to disable caching in the browser. Shouldn’t the default behavior be the opposite of this?

  26. glikoz

    ASp.NEt MVc 3 and this solution are not working ..
    Someone changes cacheWriter to httpwriter ..
    !! 6 hours have gone :(

  27. Vladimir

    Thank you Steve for the great post!

    Here is an update for using this filter in Mvc 3 with varying it by params.

    public class ActionOutputCacheAttribute : ActionFilterAttribute
    {
    static MethodInfo _switchWriterMethod = typeof(HttpResponse).GetMethod(“SwitchWriter”, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);

    int _cacheDuration;
    TextWriter _originalWriter;
    string _cacheKey;
    string[] paramsToVaryBy;

    public ActionOutputCacheAttribute(int cacheDuration) : this(cacheDuration, null)
    {
    }

    public ActionOutputCacheAttribute(int cacheDuration, string varyByParam)
    {
    _cacheDuration = cacheDuration;

    var list = (varyByParam ?? “”)
    .Split(‘,’, ‘;’, ‘ ‘)
    .Where(x => !string.IsNullOrWhiteSpace(x))
    .Select(x => x.Trim())
    .ToList();

    list.Add(“controller”); // Hardcode to vary at least by controller and action
    list.Add(“action”);

    paramsToVaryBy = list.ToArray();
    }

    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
    _cacheKey = ComputeCacheKey(filterContext);
    string cachedOutput = (string)filterContext.HttpContext.Cache[_cacheKey];
    if (cachedOutput != null)
    filterContext.Result = new ContentResult { Content = cachedOutput };
    else
    _originalWriter = (TextWriter)_switchWriterMethod.Invoke(HttpContext.Current.Response, new object[] { new HtmlTextWriter(new StringWriter()) });
    }

    public override void OnResultExecuted(ResultExecutedContext filterContext)
    {
    if (_originalWriter != null)
    {
    HtmlTextWriter cacheWriter = (HtmlTextWriter)_switchWriterMethod.Invoke(HttpContext.Current.Response, new object[] { _originalWriter });
    string textWritten = ((StringWriter)cacheWriter.InnerWriter).ToString();
    filterContext.HttpContext.Response.Write(textWritten);

    filterContext.HttpContext.Cache.Add(_cacheKey, textWritten, null, DateTime.Now.AddSeconds(_cacheDuration), Cache.NoSlidingExpiration, System.Web.Caching.CacheItemPriority.Normal, null);

    _originalWriter = null;
    }
    }

    private string ComputeCacheKey(ActionExecutingContext filterContext)
    {
    var keyBuilder = new StringBuilder();

    var routeData = filterContext.RouteData.Values.Where(x => paramsToVaryBy.Contains(x.Key));
    foreach (var pair in routeData)
    keyBuilder.AppendFormat(“rd{0}_{1}_”, pair.Key.GetHashCode(), pair.Value.GetHashCode());

    var actionParams = filterContext.ActionParameters.Where(x => paramsToVaryBy.Contains(x.Key));
    foreach (var pair in actionParams)
    keyBuilder.AppendFormat(“ap{0}_{1}_”, pair.Key.GetHashCode(), pair.Value.GetHashCode());

    return keyBuilder.ToString();
    }
    }

    BTW, Steve, are you plannig to update your book for Asp.Net Mvc 3?

  28. Vladimir

    Also, I would notice that this solution works only for partial (e.g. ascx) views or for views without master pages, because otherwise it will cache to cache all your output html including those which is rendered by master page. So you have to warp your views with partial views and include them using RenderAction() method

  29. “I’d rather see a finished 1.0 RTM release this year than fuss about output caching.”

    And this article still holds true almost 3 years later.

  30. dani

    Excellent post, that solve a lot of time for me and is clear and working solution. Just add the two points: _originalWriter should be returned in null if cache had been invalidated, becouse we check it in onResultExecuted

    if (cachedOutput != null)
    {
    filterContext.Result = new ContentResult { Content = cachedOutput };
    _originalWriter = null;
    }

    and update to cachekey forming function:
    if (pair.Value.GetType() != typeof(DictionaryValueProvider))

    keyBuilder.AppendFormat(“rd{0}_{1}_”, pair.Key.GetHashCode(), pair.Value == null ? 0 : pair.Value.GetHashCode());

    it handle nullable params and RenderAction with RouteValueDictionary instead of anonymous type

  31. The very next time I read a blog, I hope that it does not fail me as much as this particular one. After all, Yes, it was my choice to read, but I really thought you would probably have something helpful to talk about. All I hear is a bunch of crying about something that you could possibly fix if you weren’t too busy searching for attention.