Website Performance with ASP.NET - Part4 - Use Cache Headers

“The fastest HTTP request is the one not made.” This simple rule can lead to faster page load times and less server load, without extensive performance optimization.

In the last post, we tried to reduce the number of HTTP requests by combining and deferring them. That's a good idea, but wouldn't it be even better to not even make the requests? If a resource is required, it must of course be downloaded by the client. If you make use of the browser's cache however, you can prevent the client from downloading static resources again and again. This way a user browsing your website must only load your global javascript and css files once, and when he returns later, he doesn't have to download them at all. For content which is more dynamic, you can use conditional GET requests which will get an empty result if the resource hasn't changed. This offers a nice trade-off between performance and freshness.

Prevent Requests with Cache Headers

To prevent request from being made multiple times, the headers Expires and Cache-Control: max-age can be used. They tell the browser how long the resource may be delivered from cache without another requests to the server. These two are redundant, they only differ in format. The Expires header defines a fixed date to which the resource may be cached, e.g. "Expires: Thu, 02 April 2014 14:21:12 GMT". The date must be formatted according to RFC 1123. In .NET you can use .ToString("r") on a DateTime object for that. The Cache-Control: max-age header on the other hand, defines how many seconds a resources may be delivered from cache, e.g. "Cache-Control: max-age=3600". The Cache-Control: max-age header was introduced in HTTP/1.1 and is not supported by some older browsers, so you are on the safe side if you set both, or only the expires headers. You can also control if the response should only be cached by the client (Cache-Control: private) or if it also can be cached by proxies (Cache-Control: public). If you want to prevent caching of a resource, you can use Cache-Control: no-cache or Cache-Control: no-store. According to the specifications no-cache should not prevent caching, but only allow serving from cache after a revalidation with the server. In reality, different browsers (or browser versions) interpret it inconsistently, so it is safer just to use no-store which indicates the resource should not be stored in a cache at all. For more detailed information about the cache-headers, check out the RFC

There are several ways to set cache headers. Which one is suitable depends on several parameters like the type of the resource (static vs. dynamic, content type etc.) or the flexibility you want to have.

Setting Cache Headers in IIS

For static files directly deliverd by IIS, it can be as easy as adding some configuration to the Web.Config file. The following snippet allows caching of static files until May 2023.

<configuration>
    <system.webServer>
        <staticContent>
            <clientCache cacheControlMode="UseExpires" httpExpires="Mon, 01 May 2023 00:00:00 GMT" />
        </staticContent>
    </system.webServer> 
</configuration>

Setting Header Programatically

You can also set the cache headers programmatically. This can be useful for generated content and allows more fine granular and flexible control of the cache headers. The example below allows clients as well as proxies to cache all responses for one hour. You can of course use different settings depending on any parameter/content/config value etc.

public class CachingModule : IHttpModule
{
    public void Init(HttpApplication context)
    {
        context.PreSendRequestHeaders += this.SetDefaultCacheHeader;
    }

    private void SetDefaultCacheHeader(object sender, EventArgs eventArgs)
    {
        HttpContext.Current.Response.Cache.SetCacheability(HttpCacheability.Public);
        HttpContext.Current.Response.Cache.SetMaxAge(TimeSpan.FromSeconds(3600));
        HttpContext.Current.Response.Cache.SetExpires(DateTime.UtcNow.AddSeconds(3600));
    }
    ...
}

Using the Output Cache

If you want to set cache headers per page/action, you can also just use the the output cache which we already discussed when looking at server side caching. The output cache not only stores the content of a page on the server, it also sets the corresponding cache headers. E.g. if you define a cache validity of one day for the output cache, the content will be stored on the server for one day and the cache headers will automatically be set to also allow clients to cache it for one day.

Far Future Expires Header

Static resources which do not change often should have a far future expires header. According to the specification, the date must not be farther in the future than one year, but it is a common practice to use even longer time spans. This is especially useful if the expiration date is defined in a static configuration and you don't want to adapt it continuously.
Even if it does not happen often, stable resources may change from time to time. Therefor you need ensure that clients get the new version even if they have the old one in the cache. The easiest way to do that is to change the url of the resource, e.g. by adding a version number or a hash of the content. You could use a helper method like the one below to just add a hash to the query string.

public static string ContentWithHash(this UrlHelper urlHelper, string contentPath)
{
    string path = urlHelper.Content(contentPath);

    IObjectCache cache = DependencyResolver.Current.GetService<IObjectCache>();
    string hash = cache.GetOrAdd(contentPath, () => GetMd5ForFileContent(urlHelper, contentPath));

    return string.Format("{0}{1}hash={2}", path, path.Contains("?") ? "&" : "?", hash);
}

Reduce Request Payload with Cache Headers

Even if you always want the client get the newest content, you can use cache headers to improve the performance. For this to work, you have to use the Last-Modified or the Etag header, which are used to determine if a resource has to be delivered again. In both cases the server includes a value in the response which the client sends along with the next request to the same resource. The server can then use the value to check whether the client already has the newest content, or if the content changed and has to be transferred again. If the client already has the newest content, the server sends an empty response with the status code 304 Not Modified. This saves computation time on the server as well as transfer time between server and client. Both results in a shorter load time for the client. The two headers are redundant, but google recommends the Last-Modified over the Etag header, as the browser might decide to not even send a request to the server if the resource hasn't changed in a long time. This sequence diagram shows two requests to the same resource, which has not changed between the requests and is therefor not delivered again.

304 handling sequence diagram

You can use something like the following snippet to send 304 responses, you just need to include the Last-Modified header when you deliver the content (e.g. in you controller or aspx page).

public class CachingModule : IHttpModule
{
    public void Init(HttpApplication context)
    {
        context.BeginRequest += this.CheckIfModifiedSinceHeader;
    }

    private void CheckIfModifiedSinceHeader(object sender, EventArgs eventArgs)
    {
        DateTime clientContentTimestamp;
        DateTime.TryParse(HttpContext.Current.Request.Headers["If-Modified-Since"], out clientContentTimestamp);
        
        if (GetLastModifiedDateForRequestedResource() < clientContentTimestamp)
        {
            HttpContext.Current.Response.StatusCode = (int)HttpStatusCode.NotModified;
            HttpContext.Current.Response.StatusDescription = "Not Modified";
            HttpContext.Current.Response.End();
        }
    }
    // ...
}

As mentioned at the beginning, cache headers can help to improve your website's performance with minimum effort. Make sure to at least go for the low hanging fruits by using standard features like the output cache or static cache headers in the web.config file.
comments powered by Disqus