Implementing 103 Early Hints with Cloudflare Workers & HTMLRewriter! Part 1
What are 103 Early Hints?
103 Early Hints, a new informational HTTP status code to provide resource hints to clients before the final response is ready, has landed in Chrome 103. If you haven't already read the blog from Cloudflare about how it works & their performance benchmarks, you definitely should!
How do they help my site?
Naturally, what the benchmarks say and what you perceive in reality will vary. Every site is different and the benefits of preloading assets on site A will be different to site B. In our case, we saw big improvements.
Don't worry about it only being a single run, both with and without were tested at least 3+ times and the above results are the closest ones to the average. This is a huge win, reducing our First Contentful Paint by 675ms, Largest Contentful Paint by a full second(!) and reducing our webpagetest.org Speed Index by 923ms.
In those tests, we were only adding our largest images that appear in the viewport straight away as well as JS/CSS - we'll go over the dangers of adding too many preloads later so keep that in mind.
Colin Bendell, Director, Performance Engineering at Shopify summarized it best: "when a buyer visits a website, if that first page that (they) experience is just 10% faster, on average there is a 7% increase in conversion"
With our site being 40% faster as far as the speed index is concerned, that'd be a theoretical 28% boost in conversion! In reality, that's not the only metric for site speed but it's a pretty good motivation to see what early hints can do for you.
How do I start using them?
Should be easy! Add in a few link
headers, enable Early Hints in Cloudflare and you're off to the races, right?
That's all well & good but only if you have the ability (or access) to do that - if you're using a managed hosting provider, don't have the technical ability yourself or your chosen platform/CMS doesn't have that functionality then don't worry! This is where Cloudflare Workers and HTMLRewriter come into play.
We can use HTMLRewriter to parse your website's HTML, extract resources like images, stylesheets and JavaScript, add them into link
headers and then Cloudflare can generate and cache early hint's responses from those. This requires 0 changes to your website and just needs you to deploy a Cloudflare Worker.
The code, explained
If you're experienced with JavaScript or Cloudflare Workers, you might just want to skip right onto the source code which is available on my GitHub. If not, stick around and we'll go over the code to explain the implementation and how you can change it to suit your needs.
export default {
async fetch(request: Request, env: {}, ctx: ExecutionContext) {
ctx.passThroughOnException();
},
};
First things first, we'll be using the Module Worker syntax which means exporting a fetch
handler for the FetchEvent. We'll also call ctx.passThroughOnException()
so that in the event that the Worker throws an exception, the request will go directly to your website instead of showing a Worker error page.
const response = await fetch(request);
if (response.status !== 200) {
console.log(`${request.url} returned (${response.status}), skipping`);
return response;
}
const contentType = response.headers.get("content-type");
if (contentType === null || !contentType.startsWith("text/html")) {
console.log(`${request.url} is (${contentType}), skipping`);
return response;
}
Cloudflare will only generate early hints for:
- For URIs with
.html
,.htm
, or.php
file extensions, or no file extension - On 200, 301, or 302 response return codes
- When the response contains
link
headers with preconnect or preload rel types, such asLink: </img/preloaded.png>; rel=preload
We're taking care of the first two conditions here (there's no HTML for us to parse on a 301/302 so we're not considering those) and then we'll be adding in the link
headers later on.
/* PreloadResourceHint type
interface PreloadResourceHint {
url: string;
fileType: "style" | "image" | "script";
}
*/
const preloads: PreloadResourceHint[] = [];
class ElementHandler {
element(element: Element) {
switch (element.tagName) {
case "img": {
const url = element.getAttribute("src");
if (url && !url.startsWith("data:")) {
preloads.push({ url, fileType: "image" });
}
break;
}
case "script": {
// snip
}
case "link": {
// snip
}
}
}
}
The element handlers are pretty much the same so we'll just focus on one - the img
handler. We'll get the contents of the src
attribute & check if that exists as well as checking that it isn't a data URL. Data URLs are not valid resource hints so we don't want to include those.
Assuming that's all fine, we'll push it onto our preloads
array which we iterate over later to add the link
headers.
const transformed = new HTMLRewriter()
.on("img:not([loading=lazy])", new ElementHandler())
.on("script", new ElementHandler())
.on("link[rel=stylesheet]", new ElementHandler())
.transform(response);
const body = await transformed.text();
Next, we'll want to use the aforementioned element handlers to transform the original response using HTMLRewriter - the .on
tags specify which tags we should call ElementHandler
for and the syntax for the selectors is available here. By using the E:not(s)
selector syntax, our img
selector won't match lazy-loaded images (since preloading them defeats the benefit of lazy loading).
On a smaller site, it's fine as-is - but with a larger site, you run the risk of having too many hints (or even hitting the de-facto header limit of 8KB). Make sure that you're only selecting what's going to benefit you!
Typically, if you was just rewriting HTML, you wouldn't call await transformed.text()
since that buffers the body into the Worker's memory and instead let it rewrite the HTML as it's being streamed back to the user. Since we need to parse the HTML now to populate our preloads
array, we do it now.
const headers = new Headers(response.headers);
preloads = [...new Map(preloads.map((v) => [v.url, v])).values()];
preloads.forEach((element) => {
headers.append(
"link",
`<${element.url}>; rel=preload; as=${element.fileType}`
);
});
if (env.preconnect_domains) {
env.preconnect_domains.forEach((url) => {
headers.append("link", `<${url}>; rel=preconnect`);
});
}
return new Response(body, {
...response,
headers: headers,
});
We'll create a brand new Headers object from the original response's headers which we'll use to add our link
headers onto. preloads
is an array which we remove any duplicate entries from before using the forEach method to iterate over it and then use the append method on our headers to add on our link
headers.
`preconnect_domains` is an environment variable containing an array of domains that you can provide to add preconnect
hints. Refer to the preconnect domains part of the README.
We'll create a new Response using the body we had previously and then use the spread operator inside of our options
to bring over the status
& statusText
then add on our new headers object that has the original response headers as well as our new link
headers.
How do I know if it's working?
So, how does it look in practice?
Our website has been parsed for images, stylesheets and scripts then they've been added onto a link
header.
And if use cURL to hit our website, we can see Cloudflare is emitting the 103 Early Hints response before the final response from our website is sent.
Don't overhint!
Be wary of having too many preloads - you might defeat the benefits of early hints! See what assets are render blocking, what's your largest contentful paint and preload those as opposed to just everything.
How could you achieve this? Use data attributes to tag your important assets and change the selectors from .on("img"
to something like .on("img[data-hint=preload]"
.
Source code and instructions
If you didn't catch it at the start, the source code for the Worker & instructions on how to deploy it are available on my GitHub.
This blog post will be updated periodically to keep the code snippets up-to-date with the latest release - keep an eye on the changelog!
Part 2?
If you're interested in seeing what benefits Early Hints can bring to an e-commerce environment, I'll be making a follow-up post with our real-world experiences and statistics after a few weeks of usage!