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.

Prefetch compared with prerender Two timelines. Prefetch downloads the document ahead of time so the navigation skips the network but still parses and renders. Prerender parses, executes and renders ahead of time so activation only swaps the document in. prefetch — the bytes are ready download ahead of time user clicks parse execute + render visible here prerender — the page is ready download parse render swap visible almost instantly The cost of the speed A prerendered page really runs: its scripts execute, its load requests are issued, its timers start. Any route whose mere loading changes server state must be excluded, not merely deprioritised.
Prefetch removes the network from the navigation; prerender removes almost everything — at the price of running the page speculatively.

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
});
Deciding whether a route is safe to prerender A decision tree. If loading the route changes server state, exclude it. If it fires analytics or timers, defer them until activation. If it is expensive to render, lower the eagerness. Otherwise prerender it at moderate eagerness. candidate route from a document rule Does loading it change server state? logout, delete, mark-read yes no Exclude with a not/href_matches rule lowering eagerness is not a substitute Prerender at moderate eagerness defer analytics and timers to prerenderingchange drop to conservative if the page is expensive
The rule syntax is trivial; the decision that matters is which routes must never be loaded speculatively at all.

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.prerendering and the prerenderingchange event 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 immediate eagerness 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.

The four eagerness levels compared Four eagerness values with the interaction that triggers each and the bandwidth risk it carries. Immediate fires on parse and is dangerous with a broad selector; conservative fires on pointer down and wastes almost nothing. Eagerness settings and what each costs fires when bandwidth risk immediate the rule is parsed very high — never use with a broad selector eager the pointer moves near a link high on dense pages moderate after roughly 200ms of hover modest — the sensible default conservative on pointer or touch down minimal, but least lead time
Eagerness is the whole cost dial: the same rule set is either a modest win or a bandwidth incident depending on this one field.