Skip to content
Speed

Prefetching on hover: the next page is ready before you click

Luremo

Craft CMS studio

Two timelines. Without prefetch the wait for the server only starts after the click; with prefetch the browser fetches the page during the hover, so it is on screen at once after the click.
Without prefetch the wait starts at the click. With prefetch it is already over by then.

With Speculation Rules the browser fetches a page as soon as your pointer rests on a link. How it works, which browsers take part and how we built it into this site, with a playground to try it yourself.

Why a click always takes a moment

When you click a link, your browser only then starts on the next page. It asks the server for the HTML and waits for the first bytes to arrive. Only after that can it fetch stylesheets, fonts and images. That first stretch, from click to first byte, is the time to first byte. On a fast server it is a fraction of a second, but over a mobile connection or on a busy server it quickly adds up.

Between pointing at a link and clicking it there are usually a few hundred milliseconds. That is just enough to get that first stretch out of the way. That is the idea behind prefetching: the browser fetches the page while the visitor is still deciding, so the click only has to show what is already there.

Speculation Rules: giving the browser a hint

The Speculation Rules API is a set of rules in JSON that tells the browser which links it may load ahead. You can list individual URLs, but document rules are handier. They use URL patterns and CSS selectors to describe which links on the page qualify. When the browser acts on a rule depends on its eagerness:

  • immediate: right away, as soon as the rules arrive.
  • eager: at the slightest signal. On a desktop after a 10 ms hover; on a phone, where there is no hover, as soon as a link has been in view for 50 ms.
  • moderate: on a desktop after a 200 ms hover, or sooner if you already press the mouse button. On a phone Chrome looks at which links are still in view once you have stopped scrolling for half a second.
  • conservative: only when you press the mouse button or touch the screen.

There are two kinds of speculation. Prefetch only fetches the page's HTML and runs nothing. Prerender goes further: the browser builds the whole page out of sight, JavaScript included, so it appears instantly on the click. That is faster, but also more invasive, because scripts then run for a page the visitor may never see.

We use eager. The prefetch then starts as soon as the pointer touches a link, and by the time of the click the page has nearly always arrived. That has a price: every link the pointer crosses is fetched once, even if you do not click it. On a phone it goes further: there Chrome fetches every link that is in view for 50 ms, so scrolling through an overview loads nearly every card. Chrome holds at most two such prefetches at a time and drops the oldest, but that limits what it keeps, not what it fetches. If your server does not handle the extra requests well, choose moderate: it waits until someone actually pauses.

public/speculation-rules.json
{
    "prefetch": [
        {
            "source": "document",
            "where": {
                "and": [
                    { "href_matches": "/*" },
                    { "not": { "href_matches": "/*\\?(.+)" } },
                    {
                        "not": {
                            "href_matches": [
                                "/admin{/*}?",
                                "{/*}?/actions/*",
                                "{/*}?/contact{/*}?",
                                "{/*}?/logout{/*}?",
                                "/uploads/*"
                            ]
                        }
                    },
                    { "not": { "selector_matches": "[target=_blank]" } }
                ]
            },
            "eagerness": "eager"
        }
    ]
}

Which browsers take part

Speculation Rules come from Chromium. Chrome and Edge have supported prefetch and prerender since version 109; document rules, eagerness and the Speculation-Rules header arrived in version 121. Opera and Samsung Internet run on the same engine and take part too. Together that is over three quarters of all browsers, according to caniuse (as of September 2026).

Not every Chromium browser takes part. Brave reads the rules but never acts on them, and has no setting to change that. Firefox does not support the API, and Safari has it built in but disabled by default. Chrome can have it switched off too: through the Preload pages setting, Data Saver, or Energy Saver on a nearly empty battery.

That is not a problem, because Speculation Rules are a hint. A browser that does not know them skips them and loads the page on the click, as always. Nothing breaks; the browsers that take part are simply faster.

How we built it into this site

You can put the rules inline in the page, in a <script type="speculationrules">. We chose an HTTP header that points to a JSON file in public/. This site's Content Security Policy has to work without 'unsafe-inline' eventually. An inline rule set would need an exception for that; a header does not.

Two details are easy to get wrong. The browser only applies the file when it arrives as application/speculationrules+json, and that type is not in nginx's default list. And the file is served with Cache-Control: no-cache. An exclusion you add later is usually a correctness fix, and it should not linger in returning visitors' caches for a day. Nginx answers the check with a 304, without PHP.

The Craft control panel and previews do not get the header. In Live Preview an editor's pointer keeps resting on links in the preview. That would only send requests for the live pages, with no benefit to anyone.

.docker/nginx/nginx.conf
# The control panel and previews get no rules:
# an empty value leaves add_header out.
map $request_uri $speculation_rules_header {
    default '"/speculation-rules.json"';
    ~^/(?:admin|cpresources)(?:[/?]|$) "";
    ~[?&](?:token|x-craft-(?:live-)?preview)= "";
}

server {
    location ~ [^/]\.php(/|$) {
        # ...
        add_header Speculation-Rules $speculation_rules_header always;
    }

    # The only type the browser applies.
    location = /speculation-rules.json {
        types { }
        default_type application/speculationrules+json;
        add_header Cache-Control "no-cache";
    }
}

What must never be loaded ahead

A prefetch is an ordinary GET request, carrying the visitor's cookies. Anything that changes something or creates a session on a GET must therefore stay outside the rules. For us that is:

  • /admin, the control panel;
  • /actions/, Craft's endpoints;
  • /contact/, because the form creates a session and a CSRF token;
  • /logout, because Craft signs you out on a plain GET. Without this rule a signed-in editor would be signed out just by moving the pointer over the link;
  • /uploads/, so hovering over a PDF does not fetch the whole file;
  • links with a query string, and links that open in a new tab.

The patterns are URL Patterns, and they have their quirks. A bare * in the query also matches an empty query, so /*\?* excludes every link. Our rule is therefore /*\?(.+): at least one character after the question mark. A test in the repository checks every pattern with the URLPattern built into Node. It also fails if someone changes the contact page's URL without updating the rules.

Try it yourself below. The first button is an ordinary internal link. The second goes to the same page, but with a query string, so it falls under the exclusion. In Chrome you can see every attempt in DevTools, under Application → Speculative loads.

Playground

Point at one of the buttons, or on a phone let them sit in view for a moment, then click. The test page tells you how it arrived.

With prefetch

An ordinary internal link. As soon as the pointer reaches it, the browser fetches the page ahead.

Without prefetch

The same page with a query string. The rules skip those, so here you wait for the server.

Why no prerender yet

Prerender would make the click even faster, but then the next page's JavaScript runs before the visitor gets there. On this site that includes the cookie banner, and Google Analytics once someone consents. A page that was built ahead but never viewed must not count a pageview or load a banner. Browsers offer document.prerendering and the prerenderingchange event for this, but we first need to check how Cookiebot and GA4 handle them. Until then we stick to prefetch, which only fetches HTML and runs nothing.