By Harry Roberts
Harry Roberts is an independent consultant web performance engineer. He helps companies of all shapes and sizes find and fix site speed issues.
Written by Harry Roberts on CSS Wizardry.
N.B. All code can now be licensed under the permissive MIT license. Read more about licensing CSS Wizardry code samples…
I’ve always loved doing slightly unconventional and crafty things with simple web platform features to get every last drop out of them. From building the smallest compliant LCP, lazily prefetching CSS, or using pixel GIFs to track non-JS users and dead CSS, I find a lot of fun in making useful things out of other useful things.
Recently, I’ve been playing similar games with the Speculation Rules API.
I don’t want to go super in-depth about the Speculation Rules
API in
this post, but the key thing to know is that it provides two speculative loading
types—prefetch
and prerender
—which ultimately have the following goals:
prefetch
pays the next page’s TTFB costs up-front and ahead of time;prerender
pays the next page’s TTFB, FCP, and LCP up-front.It’s going to be very helpful to keep those two truisms in mind—prefetch
for
paying down TTFB; prerender
for LCP. This makes prefetch
the lighter of
the two and prerender
the more resource-intensive.
That’s about all you need to know for the purposes of this article.
csswizardry.com
Ever since Speculation Rules became available, I’ve used them in somewhat uninspired ways on this site:
<script type=speculationrules>
{
"prerender": [
{
"urls": [ "/2024/12/a-layered-approach-to-speculation-rules/" ]
}
]
}
</script>
<script type=speculationrules>
{
"prerender": [
{
"urls": [
"/2024/12/a-layered-approach-to-speculation-rules/",
"/2024/11/core-web-vitals-colours/"
]
}
]
}
</script>
In this scenario, I am explicitly prerendering named and known URLs, with a loose idea of a potential and likely user journey—I’m warming up what I think might be the visitor’s next page.
While these are both functional and beneficial, I wanted to do more. My site, although not very obviously, has two sides to it: the blog, for folk like you, and the commercial aspect, for potential clients. While steering people down a fast article-reading path is great, can I do more for visitors looking around other parts of the site?
With this in mind, I recently expanded my Speculation Rules to:
immediate
ly prefetch
any internal links on the page, and;moderate
ly prerender
any other internal links on hover.This fairly indiscriminate approach casts a much wider net than listed URLs, and instead looks out for any internal links on the page:
<script type=speculationrules>
{
"prefetch": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "immediate"
}
],
"prerender": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate"
}
]
}
</script>
This slightly layered approach allows us to immediate
ly pay the TTFB cost for
all internal links on the page, and pay the LCP cost for any internal link that
we hover (moderate
). These are quite broad rules as they apply to any href
on the page that matches /*
—so any root-relative link at all.
This approach works well for me as my site is entirely statically generated and served from Cloudflare’s edge. I also don’t get masses of traffic, so the risk of increased server load anywhere is minimal. For sites with lots of traffic and highly dynamic back-ends (database queries, API calls, insufficient caching), this approach might be a little too liberal.
On a recent client project, I wanted to take the idea further. They have a large and relatively complex site (many different product lines sitting under one domain) with lots of traffic and a nontrivial back-end infrastructure. Things would have to be a little more considered.
They’re a Big Site™ so an opt-in approach was the better way to go. A wildcard-like match would prove far too greedy1, and as different pages contain vastly different amounts of links, the additional overhead was difficult to predict on a site-wide scale.
Arguably the easiest way to opt into Speculations is with a selector. For example, we could use classes:
<a href class=prefetch>Prefetched Link</a>
<a href class=prerender>Prerendered Link</a>
And the corresponding Speculation Rules:
<script type=speculationrules>
{
"prefetch": [
{
"where": {
"selector_matches": ".prefetch"
},
...
}
],
"prerender": [
{
"where": {
"selector_matches": ".prerender"
},
...
}
]
}
</script>
N.B. As prerender
already includes the prefetch
phase, you’d never need both class="prefetch prerender"
; one or the other is
sufficient.
However, I’m very fond of this pattern:
<a href data-prefetch>Prefetched Link</a>
<a href data-prefetch=prerender>Prerendered Link</a>
And their respective Speculation Rules:
<script type=speculationrules>
{
"prefetch": [
{
"where": {
"selector_matches": "[data-prefetch='']"
},
...
}
],
"prerender": [
{
"where": {
"selector_matches": "[data-prefetch=prerender]"
},
...
}
]
}
</script>
It keeps all logic nicely and neatly contained in a data-prefetch
attribute.
Note that I’m using [data-prefetch='']
. This matches data-prefetch
exaxtly. If I were to use [data-prefetch]
, it would match any and all of the
following:
<a href data-prefetch>
<a href data-prefetch=prerender>
<a href data-prefetch=foo>
<a href data-prefetch='baz bar foo'>
<a href data-prefetch=false>
The last one is the one I care about the most, and will become very important right about… now.
We’ll probably run into a scenario at some point where we explicitly want to opt
out of prefetching or prerendering—for example, a log-out page. In order to be
able to achieve that, we’ll need to reserve something like
data-prefetch=false
.
If we’d used "selector_matches": "[data-prefetch]"
above, that would also
match data-prefetch=false
, which is exactly what we don’t want. That’s why we
bound our selector onto "selector_matches": "[data-prefetch='']"
specifically—only match a data-prefetch
attribute that has no value.
Now, we have the following three explicit opt-in and -out hooks:
data-prefetch
: Only prefetch this link.data-prefetch=prerender
: Make a full prerender for this link.data-prefetch=false
: Do nothing with this link.<a href data-prefetch>Prefetched Link</a>
<a href data-prefetch=prerender>Prerendered Link</a>
<a href data-prefetch=false>Untouched Link</a>
Anything else would fail to match any Speculation Rule, and thus would do nothing.
With these simple opt-in and -out mechanisms in place, I wanted to look at ways to subtly and effectively layer this up to add further disclosed functionality without any additional configuration. What could I do to really maximise the benefit of Speculation Rules with just these two attributes?
My thinking was that if we’re explicitly marking data-prefetch
and
data-prefetch=prerender
, could we upgrade the former to the later on-demand?
When the page loads, the browser immediately fulfils its prefetches and
prerenders, but when someone hovers a prefetched link, expand it to a full
prerender?
Easy.
And then, for good measure, can we upgrade any other internal link from nothing to prefetch on demand?
Also easy!
Working from most- to least-aggressive, and keeping in mind our two truisms, the best way to think about what we’re achieving is that we:
"prerender": [
{
"where": {
"selector_matches": "[data-prefetch=prerender]"
},
"eagerness": "immediate"
},
...
]
"prefetch": [
{
"where": {
"selector_matches": "[data-prefetch='']"
},
"eagerness": "immediate"
},
...
],
"prerender": [
...
{
"where": {
"selector_matches": "[data-prefetch='']"
},
"eagerness": "moderate"
}
]
"prefetch": [
...
{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "selector_matches": "[data-prefetch=false]" } }
]
},
"eagerness": "moderate"
}
],
Note that here is where we prefetch any internal link except those explicitly opted out.
Now, the client has the ability to prerender highly likely or encouraged
navigations with the data-prefetch=prerender
attributes (e.g. on their
top-level navigation or their homepage calls-to-action).
Things that are less likely but still reasonable candidates for warm-up (e.g.
items in the sub-navigation) can simply carry data-prefetch
.
All other internal links ("href_matches": "/*"
)—except the already-maxed out
data-prefetch=prerender
or opted-out data-prefetch=false
—get upgraded to the
next category on demand.
Putting them all together in the format and order required, our Speculation Rules look like this:
<!--! Content by Harry Roberts, csswizardry.com, available under the MIT license. -->
<script type=speculationrules>
{
"prefetch": [
{
"where": {
"selector_matches": "[data-prefetch='']"
},
"eagerness": "immediate"
},
{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "selector_matches": "[data-prefetch=false]" } }
]
},
"eagerness": "moderate"
}
],
"prerender": [
{
"where": {
"selector_matches": "[data-prefetch=prerender]"
},
"eagerness": "immediate"
},
{
"where": {
"selector_matches": "[data-prefetch='']"
},
"eagerness": "moderate"
}
]
}
</script>
We could apply these against this example page:
<ul class=c-nav>
<li class=c-nav__main>
<a href=/ data-prefetch=prerender>Home</a>
<li class=c-nav__main>
<a href=/about/ data-prefetch=prerender>About</a>
<ul class=c-nav__sub>
<li>
<a href=/about/history/ data-prefetch>Company History</a>
<li>
<a href=/about/board/ data-prefetch>Company Directors</a>
</ul>
<li class=c-nav__main>
<a href=/services/ data-prefetch=prerender>Services</a>
<ul class=c-nav__sub>
<li>
<a href=/services/solutions/ data-prefetch>Solutions</a>
<li>
<a href=/services/industries/ data-prefetch>Industries</a>
</ul>
<li class=c-nav__main>
<a href=/contact/ data-prefetch=prerender>Contact Us</a>
<li class=c-nav__main>
<a href=/log-out/ data-prefetch=false>Log Out</a>
</ul>
...
<a href=/sale/
class=c-button
data-prefetch=prerender>Black Friday Savings!</a>
...
<footer>
<a href=/sitemap/>Sitemap</a>
</footer>
data-prefetch=prerender
(e.g. the About
page) are immediately prerendered.data-prefetch
(e.g. the Solutions page)
are immediately prefetched but prerendered on demand.data-prefetch=false
are skipped entirely.I can’t publish any names or numbers or facts or figures, but we ran an experiment for a week and the outcomes we’re incredibly compelling.
I guess my point after all of this is that I think this is quite an elegant pattern and I’m quite happy with myself. If you’d like to be happy with me, too, I’m taking on new clients for 2025.
Thanks to Barry Pollard for sense-checks and streamlining.
Chrome sets sensible limits to prevent anything seriously bad happening. ↩
N.B. All code can now be licensed under the permissive MIT license. Read more about licensing CSS Wizardry code samples…
Harry Roberts is an independent consultant web performance engineer. He helps companies of all shapes and sizes find and fix site speed issues.
Hi there, I’m Harry Roberts. I am an award-winning Consultant Web Performance Engineer, designer, developer, writer, and speaker from the UK. I write, Tweet, speak, and share code about measuring and improving site-speed. You should hire me.
You can now find me on Mastodon.
I help teams achieve class-leading web performance, providing consultancy, guidance, and hands-on expertise.
I specialise in tackling complex, large-scale projects where speed, scalability, and reliability are critical to success.