Speculation Rules for Route Prerendering
After reading this you will be able to ship a speculation rules script that has the browser prefetch or fully prerender likely next documents — and know which routes must be excluded because prerendering them would have side effects.
← Back to Prefetching & Preloading Routes
Prerequisites
Core Concept
Hover prefetching, described elsewhere in this section, is a JavaScript technique: your code guesses, your code fetches, your code stores. The Speculation Rules API moves that responsibility into the browser. You declare which URLs are likely and how aggressively to act on them; the browser decides when to spend bandwidth, honours the user’s data-saver preference, and — crucially — can go further than any script can.
There are two actions, and the difference between them is large.
prefetch downloads the document into the browser’s cache. Navigating afterwards skips the network but still parses, executes and renders from scratch. It is cheap, safe, and roughly equivalent to what a hover prefetch achieves, minus the code.
prerender loads and renders the entire page in a hidden tab-like context: HTML parsed, subresources fetched, scripts executed, layout performed. Activating it swaps the hidden document in, and the navigation completes in single-digit milliseconds. It is dramatically faster and dramatically more expensive — you are paying for a full page load that may never be used.
Because a prerendered page genuinely runs, everything it does happens whether or not the user ever visits. Analytics fire, timers start, and any request the page makes on load is issued. The browser mitigates this by deferring some APIs until activation, but it cannot make a DELETE request harmless. Deciding what to exclude is therefore the substance of the work; the syntax is the easy part.
Implementation
Rules ship as a <script type="speculationrules"> block containing JSON. Document rules let the browser pick links itself using a CSS-selector-based predicate, which is far more maintainable than enumerating URLs.
<!-- Chromium 121+. Safe to ship everywhere: unsupported browsers ignore the block. -->
<script type="speculationrules">
{
"prerender": [
{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/logout*" } },
{ "not": { "href_matches": "/*/delete*" } },
{ "not": { "selector_matches": "[data-no-prerender]" } },
{ "not": { "selector_matches": "[rel~=nofollow]" } }
]
},
"eagerness": "moderate"
}
],
"prefetch": [
{
"where": { "href_matches": "/reports/*" },
"eagerness": "conservative"
}
]
}
</script>
eagerness is the dial that decides how much bandwidth you are willing to spend on a guess:
| Value | Triggered by | Use for |
|---|---|---|
immediate |
As soon as the rule is parsed | One or two known-next URLs, never a broad selector |
eager |
Slight hover or early pointer movement | Small sets where the hit rate is high |
moderate |
~200 ms hover, or pointer-down | The sensible default for general link prerendering |
conservative |
Pointer or touch down | Large link sets, expensive pages, uncertain intent |
Rules can also be injected at runtime, which is how a single-page application adapts them to the current view:
// TypeScript 5.x — swap the rule set when the route changes
let ruleScript: HTMLScriptElement | null = null;
export function setSpeculationRules(urls: string[]): void {
if (!HTMLScriptElement.supports?.("speculationrules")) return; // silently unsupported
ruleScript?.remove(); // replacing the script replaces the whole rule set
ruleScript = document.createElement("script");
ruleScript.type = "speculationrules";
ruleScript.textContent = JSON.stringify({
prerender: [{ urls, eagerness: "moderate" }],
});
document.head.append(ruleScript);
}
Finally, the prerendered page itself must behave. Defer anything that should only happen when the user is actually looking at it:
// TypeScript 5.x — inside the page that may be prerendered
function onActivated(run: () => void): void {
if (!document.prerendering) {
run(); // ordinary load
return;
}
// Fires when the hidden prerender is swapped in and becomes visible.
document.addEventListener("prerenderingchange", () => run(), { once: true });
}
onActivated(() => {
analytics.pageView(location.pathname); // otherwise counted for pages never seen
startSessionTimer();
markNotificationsRead(); // a side effect that must NOT run speculatively
});
Auditing a route table for prerender safety
Work through the table once and sort every route into three buckets. The exercise takes an hour and prevents the class of incident that makes teams disable speculation entirely after one bad week.
Never prerender. Any route whose GET request mutates state — a logout link, a “mark all read” endpoint dressed as a page, an unsubscribe confirmation, a one-time invitation acceptance. These are usually violations of HTTP semantics that nobody noticed until prerendering started exercising them. Fix the semantics if you can; exclude them by pattern either way.
Prerender with deferrals. Routes that are safe to load but whose instrumentation must wait: anything firing a page view, starting a session timer, opening a WebSocket, requesting geolocation, or beginning media playback. All of these belong behind the prerenderingchange gate. The page is otherwise a perfectly good prerender candidate, and these are the routes where the technique pays off most because they tend to be the heavy ones.
Prerender freely. Static content, documentation, product pages, search results — anything whose load is a pure read. This is usually the majority of a content site’s table, and it is where most of the perceived-speed win lives.
One more consideration cuts across all three: cost at the origin. A route that takes 800 ms of server time to render is expensive to speculate on, and a broad moderate rule over a page of such links can multiply your origin traffic. For those, drop to conservative, which waits for pointer-down and therefore fires far closer to a genuine click.
Verification
In Chromium DevTools, the Application panel has a Speculative Loads section listing every rule, every candidate URL, and — most usefully — the reason any candidate was rejected. “Not eligible” almost always means a rule matched an exclusion, and the panel names which one.
// @playwright/test v1.44 — assert the rules exist and exclude what they must
import { test, expect } from "@playwright/test";
test("speculation rules are present and exclude side-effecting routes", async ({ page }) => {
await page.goto("/");
const rules = await page.locator('script[type="speculationrules"]').textContent();
const parsed = JSON.parse(rules ?? "{}");
expect(parsed.prerender?.[0]?.eagerness).toBe("moderate");
const serialised = JSON.stringify(parsed);
expect(serialised).toContain("/logout*"); // the exclusion is present
});
test("analytics do not fire for a prerendered page that is never activated", async ({ page }) => {
let pageViews = 0;
await page.route("**/collect*", (r) => { pageViews++; r.fulfill({ status: 204 }); });
await page.goto("/");
await page.waitForTimeout(1000); // let any speculation settle
expect(pageViews).toBe(1); // the current page only
});
For the payoff itself, measure activation rather than load: on a prerendered navigation, performance.getEntriesByType("navigation")[0].activationStart is non-zero, and Largest Contentful Paint is reported relative to activation — which is why prerendered pages routinely post sub-100 ms LCP.
Gotchas
- Prerendering runs your page. Anything in a load handler happens speculatively;
document.prerenderingand theprerenderingchangeevent exist precisely so you can defer the parts that should not. - Analytics double-count or mis-count by default. Most vendors now handle prerendering, but only if you use their current snippet — older inline snippets fire on parse.
- Cross-origin URLs are not prerendered by document rules without additional opt-in, so a rule matching
/*will still skip external links. - The browser may decline for reasons outside your control: low memory, a data-saver setting, an active battery-saver mode, or too many outstanding speculations. Never treat prerendering as a guarantee.
- A prerendered page holds a real connection to your origin. At
immediateeagerness across a large link set, that is a meaningful amount of traffic you did not intend to serve. - Rules are replaced, not merged, when you swap the script. Injecting a route-specific rule set removes the global one unless you re-include it.
FAQ
Is this a replacement for hover prefetching? For real document navigations, yes — it is more capable and needs no code. For a client-side router that never issues a document request, it does not apply, and the hover prefetch technique remains the right one.
What happens in browsers without support? The script block is ignored entirely. There is no polyfill and none is needed; the page simply loads normally.
How much bandwidth does this cost? At moderate eagerness the browser only acts on a deliberate hover, so the waste is bounded by how often users hover without clicking — typically modest. At immediate across a whole page of links it can be enormous.
Does prerendering affect Core Web Vitals? Yes, favourably and legitimately: metrics are measured from activation, so a prerendered page reports the near-zero LCP the user actually experienced.
Can I prerender a page behind authentication? Yes, and the credentials behave as they would on a normal navigation. Be careful with anything that consumes a one-time token, since the prerender may spend it without the user ever arriving.
Related
- Prefetching & Preloading Routes — the broader set of speculative loading strategies and when each pays off.
- Prefetching Routes on Link Hover — the JavaScript technique this API generalises for real navigations.
- Preloading Route Chunks with modulepreload — the equivalent optimisation for a client router’s JavaScript rather than its documents.