Utilizing Save-Data client hint in ASP.NET Core MVC

If you frequently log your requests you might have noticed a presence of Save-Data header (especially if you have a significant amount of traffic from mobile devices). This is not a common header, I've noticed it for the first time when I was playing with Opera in Opera Turbo mode and I've been intrigued by it. It turns out that beside Opera Turbo it's being send by both Chrome and Opera when Data Saver/Data savings option on Android versions of those browsers is enabled. The intent of this header is to hint the server that client would like to reduce data usage. This immediately gave me couple of interesting ideas.

First things first - reading the header from request

Before I could do anything useful with the header I had to get it from the request. The header definition says that its value can consist of multiple tokens, while only one (on) is currently defined. I've decided to represent this with following class.

public class SaveDataHeaderValue
{
    private bool? _on = null;

    public bool On
    {
        get
        {
            if (!_on.HasValue)
            {
                _on = Tokens.Contains("on", StringComparer.InvariantCultureIgnoreCase);
            }

            return _on.Value;
        }
    }

    public IReadOnlyCollection<string> Tokens { get; }

    public SaveDataHeaderValue(IReadOnlyCollection<string> tokens)
    {
        Tokens = tokens ?? throw new ArgumentNullException(nameof(tokens));
    }
}

Now I could create a simple extension method which would grab the raw header value from request, split it, remove any optional white spaces and instantiate the SaveDataHeaderValue.

public static class HttpRequestHeadersExtensions
{
    public static SaveDataHeaderValue GetSaveData(this HttpRequest request)
    {
        if (!request.HttpContext.Items.ContainsKey("SaveDataHeaderValue"))
        {
            StringValues headerValue = request.Headers["Save-Data"];
            if (!StringValues.IsNullOrEmpty(headerValue) && (headerValue.Count == 1))
            {
                string[] tokens = ((string)headerValue).Split(';');
                for (int i = 0; i < tokens.Length; i++)
                {
                    tokens[i] = tokens[i].Trim();
                }

                request.HttpContext.Items["SaveDataHeaderValue"] = new SaveDataHeaderValue(tokens);
            }
        }

        return request.HttpContext.Items["SaveDataHeaderValue"] as SaveDataHeaderValue;
    }
}

I'm also caching the SaveDataHeaderValue instance in HttpContext.Items so parsing happens only once per request.

Dedicated images URLs

My first idea was to be able to define different images sources depending on presence of the hint. I wanted something similar to what link and script Tag Helpers provide in form of asp-fallback-href/asp-fallback-src - an attribute which would contain alternative source. The framework provides a UrlResolutionTagHelper class which can be used as base in order to take care of the URL processing. What left for me was to check if the hint has been sent along the request and if yes replace the original value of src attribute with value from the new attribute (which I've named asp-savedata-src). I've also targeted my Tag Helper only at img elements that have both attributes.

[HtmlTargetElement("img", Attributes = "src,asp-savedata-src",
    TagStructure = TagStructure.WithoutEndTag)]
public class ImageTagHelper : UrlResolutionTagHelper
{
    [HtmlAttributeName("asp-savedata-src")]
    public string SaveDataSrc { get; set; }

    public ImageTagHelper(IUrlHelperFactory urlHelperFactory, HtmlEncoder htmlEncoder)
        : base(urlHelperFactory, htmlEncoder)
    { }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        // Validations skipped for brevity
        ...

        output.CopyHtmlAttribute("src", context);
        if (ViewContext.HttpContext.Request.GetSaveData()?.On ?? false)
        {
            output.Attributes.SetAttribute("src", SaveDataSrc);
        }
        ProcessUrlAttribute("src", output);

        output.Attributes.RemoveAll("asp-savedata-src");
    }
}

This Tag Helper can be used like this.

<img src="~/images/highres.png" asp-savedata-src="~/images/lowres.png" />

Which is exactly what I wanted and I believe looks very elegant. The approach can easily be extended on other media (for example video).

Conditional markup

The second idea was conditional markup generation. There are often areas of a page which doesn't provide important information and serve more decorative purposes. Those areas could be skipped if client has opted for reduced data usage. For this purpose a simple HtmlHelper extension should be enough.

public static class HtmlHelperSaveDataExtensions
{
    public static bool ShouldSaveData(this IHtmlHelper htmlHelper)
    {
        if (htmlHelper == null)
        {
            throw new ArgumentNullException(nameof(htmlHelper));
        }

        return htmlHelper.ViewContext.HttpContext.Request.GetSaveData()?.On ?? false;
    }
}

With this extension such noncrucial areas of the page can be wrapped in an if block.

@if (!Html.ShouldSaveData())
{
    ...
}

This allows for more fine-tuned markup delivery strategy, but this idea can be taken further.

Dedicated actions

Having conditional sections is great but having dedicated views might be better in some cases. The Save-Data header can easily become a part of action selection process. All that is needed is an attribute which implements IActionConstraint interface, which boils down to implementing the Accept method. The Accept method should return true if action is valid for the request.

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class SaveDataAttribute : Attribute, IActionConstraint
{
    private bool _on;

    public int Order { get; set; }

    public SaveDataAttribute(bool on)
    {
        _on = on;
    }

    public bool Accept(ActionConstraintContext context)
    {
        return (context.RouteContext.HttpContext.Request.GetSaveData()?.On ?? false) == _on;
    }
}

Applying attribute to actions having the same action name allows for clean separation between regular and reduced data flow.

public class DemoController : Controller
{
    [SaveData(false)]
    public IActionResult Index()
    {
        return View();
    }

    [ActionName(nameof(Index))]
    [SaveData(true)]
    public IActionResult IndexSavedData()
    {
        return View(nameof(IndexSavedData));
    }
}

This shows the power hiding behind this header. It opens a number of ways to optimize the application for clients which desire it and the samples above are just the simplest usages I could come up with. There is probably a lot more interesting usages that I haven't think of.

Couple more words about broader context

The Save-Data header is part of Client Hints proposal which aims at addressing a need to deliver optimized content for each device. The proposal contains more headers which provide information mostly about display capabilities of client. It also defines a mechanism for advertising supported hints through Accept-CH and Accept-CH-Lifetime headers. As I was going through the specification I've created a simple middleware capable of setting those headers. I'm not aware of any browser supporting those headers, so this is more like a learning example although it has one real-life use. In addition to advertising client hints support it also interacts with Vary header. It's important if the response which can be optimized is also cacheable. In such case the cache needs to know that the hint headers needs to be taken into consideration when choosing response. The middleware will add all the hint headers which has been configured to be supported to the Vary header.

I've put the projects containing the middleware and helpers build around Save-Data header up on GitHub.