Priority Hints is rolling out to Chrome in the 101 release which is currently available in the Dev/Beta channel of Chrome and available in WebPageTest when using the Chrome Canary
browser selection.
To make it easier to experiment with priority hints (particularly for LCP images) without making production changes, I set up a couple of public Cloudflare Workers that can be used dynamically with WebPageTest to inject priority hints into existing pages and to preload arbitrary images when combined with WebPageTest's overrideHost
script command.
There is a cloudflare worker at hint.perf.workers.dev
that will take a CSS selector from the x-hint
HTTP header and add fetchpriority=high
to any elements in the HTML that match the selector. The easiest way to experiment with this is to use Chrome's dev tools locally, identify the element that hosts the image, right-click on the element and use copy->selector. It may not produce the most efficient and reusable selector but it should usually work.
The worker also removes any loading attributes from the elements that match the selector in order to disable any lazy loading that might be in place.
Here is a sample WebPageTest script that will boost the priority of the hero image for a WordPress blog:
overrideHost roadtrip.meenan.us hint.perf.workers.dev
addHeader x-hint: #post-1739 > div > figure:nth-child(2) > img
navigate https://roadtrip.meenan.us/?p=1739
There is a cloudflare worker at preload.perf.workers.dev
that will take an image URL from the x-preload
HTTP header and add a <link rel="preload" as="image" fetchpriority="high" ...>
tag to the HTML just before the close of the <head>
tag which should be the optimal place in most conditions to preload an image so it loads after any blocking scripts. This can be useful for testing high-priority preloads for background images or JS-injected images.
Here is a sample WebPageTest script that will preload the hero image for a WordPress blog:
overrideHost roadtrip.meenan.us preload.perf.workers.dev
addHeader x-preload: https://roadtrip.meenan.us/wp-content/uploads/2020/08/img_6217.jpg
navigate https://roadtrip.meenan.us/?p=1739
Rewriting pages to go through the Cloudflare worker changes the performance even if no other changes are made. In order to get a clean baseline for comparison, you should test with the same script you are using for the experiment but with the Chrome command-line flag (in advanced settings) of --disable-blink-features=PriorityHints
. You could also just remove the addHeader command from the script but disabling the Chrome feature will give you the cleanest baseline.
Here is a comparison of both of the sample scripts above along with a comparison baseline. Both scripts had a similar impact, dropping LCP from 2.2 seconds down to 1.3 seconds for the desktop site on a Cable connection.
The filmstrip also demonstrates the tradeoffs involved with the image loading sooner but the custom-styled text loading later. This could be significantly improved by self-hosting the fonts instead of using Google fonts and by preloading the custom font used for the heading.
For completeness (and as an example), here are the worker scripts used for both.
hint.perf.workers.dev:
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
});
async function handleRequest(request) {
const url = new URL(request.url);
const host = request.headers.get('x-host');
if(!host) {
return new Response('x-host header missing', {status: 403});
}
url.hostname = host;
const selector = request.headers.get('x-hint');
const acceptHeader = request.headers.get('accept');
if(selector !== null && (acceptHeader !== null && acceptHeader.indexOf('text/html') >= 0)) {
const response = await fetch(url.toString(), request);
return new HTMLRewriter()
.on(selector.trim(), new prioritizeHigh())
.transform(response);
}
// Otherwise just proxy the request
return fetch(url.toString(), request)
}
class prioritizeHigh {
element(element) {
element.removeAttribute('loading');
element.setAttribute('fetchpriority', 'high');
}
}
preload.perf.workers.dev:
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
});
async function handleRequest(request) {
const url = new URL(request.url);
const host = request.headers.get('x-host');
if(!host) {
return new Response('x-host header missing', {status: 403});
}
url.hostname = host;
const preloadUrl = request.headers.get('x-preload');
const acceptHeader = request.headers.get('accept');
if(preloadUrl !== null && (acceptHeader !== null && acceptHeader.indexOf('text/html') >= 0)) {
const response = await fetch(url.toString(), request);
return new HTMLRewriter()
.on('head', new Preload(preloadUrl))
.transform(response);
}
// Otherwise just proxy the request
return fetch(url.toString(), request)
}
class Preload {
constructor(preloadUrl) {
this.preloadUrl = preloadUrl;
}
element(element) {
const insertHTML = '\n<link rel="preload" href="' + this.preloadUrl + '" as="image" fetchpriority="high">\n';
element.append(insertHTML, {html: true});
}
}