Cache headers for MVC File Action Result (ASP.NET Core 2.0)

You typically work with one of the following action results when you want to write a file to the response.

  • FileContentResult (writes a binary file to response)
  • FileStreamResult (writes a file from a stream to the response)
  • VirtualFileResult (writes a file specified using a virtual path to the response)

These are all coming from ASP.NET Core's MVC package. In earlier version of ASP.NET Core (1.*); using these action results, you could have served files but there wasn't any way of configuring cache headers for the responses.

I'm pretty sure that you are familiar with StaticFiles middleware of the framework. All it does is serve static files (css, javascript, image etc.) from a predefined/configurable file location (typically from inside the web root i.e. wwwroot folder). But along the way it also does some cache headers configuration. The reason behind this is you don't want your server to be called up every time for a file that doesn't change frequently. Let's see how a request for a static file looks like in the browser's network tab.

So, when the middleware starts processing the request it also adds a Etag and a Last-Modified header with it before sending it to the browser as a response. Now if we request for the same file again the browser will directly serve the file from its cache rather than make a full request to the server.

On a subsequent request such as above the server validates the integrity of the cache by comparing the If-Modified-Since,If-None-Match header values with the previously sent Etag and Last-Modified header values. If it matches; means our cache is still valid and the server sends a 304 Not Modified status to the browser. On the browser's end, it handles the status by serving up the static file from its cache.

You may be wondering that, to make sure whether the response is cached or not I'm calling the server again to give me a 304 Not Modified status. So how it benefitw me? Well, although it's a real HTTP request to the server but the request payload is much less than a full body request. Means that once the request passes cache validation checks, it is opted out of any further processing. (the processing tunnel may include an access to the database or whatever)

You can add additional headers when serving up static files by tweaking the middleware configuration. For example, the following setting adds a Cache-Control header to the response which notifies the browser that it should store the cached response for 10 minutes (max-age=600) only. The public field means that the cache can be stored in any private (user specific client/browser) cache store or some in between cache server or on the server itself (memory cache). The max-age field also specified that when the age of the cache is expired a full body request to server should be made. Now your concern would be what about the Etag and Last-Modified headers validation. Well when you specify a Cache-Control with a max-age the Etag and Last-Modified headers just goes down into the priority level. By default, the StaticFiles middleware is configured to have a max-age of a lifetime.

app.UseStaticFiles(new StaticFileOptions()
{
    OnPrepareResponse = ctx => {
        ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=600");
    }
});

But enough about static files that is publicly accessible. What about a file we intended to serve from an MVC action? How do we add cache headers to those?

Well it wasn't possible until in the recent version of the ASP.NET Core 2.0 (Preview 2). The action results, I pointed out at the very beginning of the post are now overloaded with new parameters such as:

  • DateTimeOffset? lastModified
  • EntityTagHeaderValue entityTag

With help of this parameters now you can add cache headers to any file that you want to send up with the response. Here is an example on how you will do it:

var entityTag = new EntityTagHeaderValue("\"CalculatedEtagValue\"");
return File(data, "text/plain", "downloadName.txt", lastModified: DateTime.UtcNow.AddSeconds(-5), entityTag: entityTag);

This is just a simple example. In production, you would replace the values of entityTag and lastModified parameters with some real world calculated values. For static files discussed earlier the framework calculates the values using the following code snippet and you can use the same logic if you want:

if (_fileInfo.Exists)
{
    _length = _fileInfo.Length;

    DateTimeOffset last = _fileInfo.LastModified;
            // Truncate to the second.
    _lastModified = new DateTimeOffset(last.Year, last.Month, last.Day, last.Hour, last.Minute, last.Second, last.Offset).ToUniversalTime();

     long etagHash = _lastModified.ToFileTime() ^ _length;
     _etag = new EntityTagHeaderValue('\"' + Convert.ToString(etagHash, 16) + '\"');
 }

However, once it is setup you can make an HTTP request to your API that serves the file like the following:

One thing I should mention that although we didn't have to explicitly define the If-Modified-Since,If-None-Match headers while making subsequent requests from within the browser (cause the browser is intelligent enough to set those for us), in this case we have to add those in the header.

Anyways, you can also request for partial resource by adding a Range header in the request. The value of the header would be a to-from byte range. Following is an example of the Range header in action:

Not every resource can be partially delivered. Some partial requests can make the resource unreadable/corrupted. So, use the Range header wisely.

Last of all, how do you add other cache headers like Cache-Control in this scenario? Well you can use the Response Caching middleware and decorate your action with the ResponseCache attribute like the following:

[Route("api/[controller]")]
public class DocumentationController : Controller
{
    [ResponseCache(Duration = 600)]
    [HttpGet("download")]
    public IActionResult Download()
    {
        return File("/assets/live.pdf", "application/pdf", "live.pdf", lastModified: DateTime.UtcNow.AddSeconds(-5), entityTag: new Microsoft.Net.Http.Headers.EntityTagHeaderValue("\"MyCalculatedEtagValue\""));
    }
}

Of course, that is an option. You can modify the response with your custom code also. A response with a Cache-Control header added would be like the following:

And that's it! I hope you enjoyed reading the post. But that's not all about caching. Other than files you can add caching to any entity you want. But that's for later. If you want a blog on that topic, let me know in the comment.

Git Repository of the demo - https://github.com/fiyazbinhasan/Range-Etags-LastModified-in-File-Action-Result