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.