Speculation Rules: Prefetch & Prerender

<link rel="prefetch"> was designed to warm the HTTP cache with a single subresource; it was never a navigation primitive. It cannot express which links deserve speculation, when user intent is strong enough to justify the bytes, or the difference between “fetch the HTML” and “have the next page fully rendered before the click”. The Speculation Rules API closes that gap: a JSON block (or HTTP response header) declares navigation candidates and an eagerness threshold, and the browser’s speculation engine handles intent detection, deduplication, caps, and — for prerender — running the entire next page in a hidden renderer that activates in milliseconds when the user commits.

This page covers the rule syntax (list vs document rules, where conditions), the four eagerness levels and their Chrome limits, the prefetch and prerender actions and their radically different cost profiles, the hidden-renderer lifecycle and how to detect activation, analytics correctness, cache and Sec-Purpose semantics, DevTools verification, and the edge cases — double-running JavaScript, deferred iframes, cross-origin restrictions — that separate a safe rollout from a support-ticket generator.


From subresource hint to navigation speculation

A classic prefetch, covered in depth in mastering link rel preload and prefetch, drops one response into a cache and hopes the next page asks for the same URL. The speculation engine operates a level higher, on navigation candidates. That changes four things:

  1. Deduplication against real navigations. If the user clicks while a speculative prefetch is in flight, the browser adopts the in-flight request instead of starting a second one. A <link rel="prefetch"> racing a navigation frequently produces a duplicate fetch.
  2. Intent-based triggering. Document rules let the browser itself watch links and speculate at hover or pointerdown — behaviour that previously required JavaScript observers and manual hint injection.
  3. A stronger action tier. Prerender does not just fetch the HTML; it fetches subresources, runs script, and lays the page out off-screen. Activation swaps the hidden document in, which is why prerendered navigations routinely report near-zero LCP.
  4. Built-in safety rails. The engine enforces per-eagerness candidate caps, skips speculation under Save-Data and on very slow connections, and marks every speculative request with a Sec-Purpose header so servers can refuse.

Like every speculative fetch, rules-based prefetches run at the bottom of the browser’s fetch priority ladder, so they do not compete with the current page’s critical path. Prerender’s network cost is similarly deprioritized, but its memory and CPU cost is real — a whole renderer process per candidate — which is why the caps are far tighter than for prefetch.

The state machine below is the mental model for everything that follows:

Speculation rules candidate lifecycle State diagram showing how a speculation rule is parsed into candidates, waits for an eagerness trigger, becomes a low-priority prefetch or a hidden prerendered renderer, and finally either activates instantly on navigation or is discarded due to caps, timeout, or memory pressure. Rules parsed script / HTTP header Candidates matched urls list / where conditions Eagerness trigger immediate / hover / pointerdown Prefetch speculation HTML response held in memory, lowest network priority Prerender speculation hidden renderer: subresources fetched, JS runs, layout computed off-screen Activation click → served from speculation: prerender swaps in near-instantly Discard cap eviction, ~10 min age, initiator navigates away Discard memory pressure, disallowed API use, header opt-out Activation fires document.prerenderingchange; discarded candidates cost bandwidth (prefetch) or bandwidth + a renderer (prerender)

Rule syntax: list rules vs document rules

Rules live in an inline <script type="speculationrules"> block, a script injected at runtime, or a JSON file referenced by the Speculation-Rules HTTP response header (served with Content-Type: application/speculationrules+json). The top-level JSON object has one array per action — prefetch and/or prerender — and each array holds rule objects.

List rules enumerate explicit URLs. They suit funnels where the next page is known — pagination, checkout steps, a “continue reading” link:

{
  "prerender": [
    {
      "urls": ["/checkout/payment/", "/checkout/review/"],
      "eagerness": "moderate"
    }
  ]
}

Document rules tell the browser to derive candidates from the links present in the document, filtered by a where condition tree. This is the declarative replacement for JavaScript link observers:

{
  "prefetch": [
    {
      "where": {
        "and": [
          { "href_matches": "/*" },
          { "not": { "href_matches": "/logout" } },
          { "not": { "selector_matches": ".no-speculate" } }
        ]
      },
      "eagerness": "moderate"
    }
  ]
}

The where grammar composes three predicates with boolean combinators:

  • href_matches — a URL pattern (URL Pattern syntax: * wildcards, :param segments, search-param matching). Patterns are resolved against the document’s base URL unless relative_to: "document" is overridden.
  • selector_matches — a CSS selector the <a> element itself must match. This is how you scope speculation to a nav region ("nav.primary a") or exclude annotated links (.no-speculate).
  • and / or / not — condition combinators, nestable to any depth.

A rule with urls is a list rule; a rule with where is a document rule. A single rule cannot contain both.

Browser engine differences

Behaviour Chromium (Blink) Safari (WebKit) Firefox (Gecko)
<script type="speculationrules"> Parsed and enforced (105+ prefetch, 109+ prerender) Ignored (unknown script type) Ignored (unknown script type)
Prerender action Full hidden-renderer prerender Not supported Not supported
Fallback for navigation warm-up <link rel="prefetch"> (subresource semantics) <link rel="prefetch"> (subresource semantics)
Sec-Purpose on speculative requests prefetch or prefetch;prerender n/a Sends Sec-Purpose: prefetch on classic prefetch (Firefox 126+)

Because unsupported engines ignore the script type wholesale, static rules are self-degrading: no errors, no wasted fetches, no behaviour change. The engineering question in WebKit and Gecko is only whether you ship a parallel classic-prefetch strategy or accept normal-speed navigations there.


Spec/API reference

Rule object fields

Field Applies to Values Notes
urls list rules array of URL strings Resolved against the rule set’s base URL; mutually exclusive with where
where document rules condition object href_matches, selector_matches, and, or, not
eagerness both conservative | moderate | eager | immediate Default: immediate for list rules, conservative for document rules
relative_to list rules (header-delivered) "document" | "ruleset" Whether URLs resolve against the page or the external rule file
referrer_policy both any referrer-policy keyword Cross-site prefetch requires a sufficiently strict policy on the outgoing referrer
requires prefetch rules ["anonymous-client-ip-when-cross-origin"] Fails the rule where the browser cannot IP-anonymise the cross-origin prefetch
expects_no_vary_search both No-Vary-Search header value Lets a click with mismatched query params still reuse the speculation
tag both string Label surfaced in DevTools and the Sec-Speculation-Tags request header for attribution

Eagerness matrix (Chromium behaviour and caps)

Eagerness Trigger Prefetch cap Prerender cap Typical use
immediate As soon as the rule is processed 50 10 Known next step in a funnel; list rules only in practice
eager Slightest intent signal — pointer moves onto the link, touch begins 50 10 Broad document-rule prefetch where hit rate matters more than bytes
moderate Hover held ~200 ms, or pointerdown 2 (FIFO) 2 (FIFO) The sane default for document rules — quicklink-style behaviour without the library
conservative Pointerdown / touchstart only 2 (FIFO) 2 (FIFO) Cheapest possible win: ~100–200 ms saved per click, minimal waste

Two operational consequences of the caps: with moderate, hovering a third link evicts the oldest of the two live speculations (first-in-first-out), so the caps self-manage on link-dense pages; and immediate/eager rules can pin up to 10 renderer processes, which is why broad eager prerender rules are almost always a mistake — reserve prerender for a handful of high-probability targets and use prefetch for breadth.

Browser support matrix

Feature Chrome Edge Firefox Safari
Speculation rules prefetch (list) 105 105
Prerender action (list rules) 109 109
Document rules + eagerness 121 121
Speculation-Rules HTTP header 121 121
HTMLScriptElement.supports('speculationrules') 105 105 returns false returns false
document.prerendering / prerenderingchange 108 108

Step-by-step implementation

Step 1 — Feature-detect and choose a delivery mechanism

Inline <script type="speculationrules"> in the HTML is the simplest delivery and works with the preparser. The Speculation-Rules header suits sites where templates are hard to touch but edge config is not — note the rules file fetch itself adds a round trip before any speculation begins.

// Scheduling rationale: rules injected at runtime are honoured the moment the
// script element enters the DOM, so gating on supports() lets non-Chromium
// browsers keep an existing prefetch strategy with zero duplicate speculation.
if (HTMLScriptElement.supports?.('speculationrules')) {
  const rules = document.createElement('script');
  rules.type = 'speculationrules';
  rules.textContent = JSON.stringify({
    prefetch: [{ where: { href_matches: '/*' }, eagerness: 'moderate' }]
  });
  document.head.appendChild(rules);
}

Step 2 — Ship a conservative prefetch document rule first

Start with the cheapest action and the strictest trigger, and carve out URLs with side effects. A prefetch of /logout that logs the user out is the canonical self-inflicted incident:

{
  "prefetch": [
    {
      "where": {
        "and": [
          { "href_matches": "/*" },
          { "not": { "href_matches": "/logout*" } },
          { "not": { "href_matches": "/cart/remove/*" } },
          { "not": { "selector_matches": "[rel~=nofollow], .no-speculate" } }
        ]
      },
      "eagerness": "conservative"
    }
  ]
}

Even conservative is worthwhile: the fetch starts at pointerdown, and the ~100–200 ms between press and click-complete is pure TTFB savings on every internal navigation. GET endpoints should be side-effect free regardless — speculation makes violations of that rule visible, it does not create them.

Once prefetch is proven safe, add prerender for the small set of links that dominate your navigation graph — scoped by selector, never site-wide:

{
  "prerender": [
    {
      "where": { "selector_matches": ".article-list a.headline" },
      "eagerness": "moderate"
    }
  ],
  "prefetch": [
    {
      "where": { "href_matches": "/*" },
      "eagerness": "moderate"
    }
  ]
}

When both actions match the same URL, Chromium attempts the stronger one; the prefetch rule still covers everything the prerender rule does not. A prerender automatically reuses a completed prefetch of the same URL for its main resource, so layering the two wastes nothing.

Step 4 — Make page JavaScript prerender-safe

Every page that can be a prerender target must tolerate loading invisibly. The pattern is one guard function:

// Scheduling rationale: a prerendered document runs its scripts before the user
// has committed to visiting — one-shot side effects (pageviews, ad auctions,
// autoplaying state, countdown timers) must wait for activation or they will
// fire for navigations that never happen and double-fire for ones that do.
function whenActivated(callback) {
  if (document.prerendering) {
    document.addEventListener('prerenderingchange', callback, { once: true });
  } else {
    callback(); // normal load — the page is already user-visible
  }
}

whenActivated(() => {
  sendPageview();          // analytics counts real views only
  startSessionTimers();    // engagement clocks begin at visibility, not fetch
});

prerenderingchange fires exactly once, at the moment the hidden document is swapped into the tab. After activation, performance.getEntriesByType('navigation')[0].activationStart is non-zero — the definitive signal, in the field, that a navigation was served by prerender.

Step 5 — Let servers see and shape speculation

Every speculative request carries a Sec-Purpose request header: prefetch for prefetches, prefetch;prerender for prerenders. Origins that must not serve speculative traffic — personalised pages behind fragile session logic, rate-limited endpoints — can return a non-2xx status (503 is conventional) or a redirect, which cancels that candidate without affecting real navigations:

# Scheduling rationale: refusing at the edge is cheaper than serving a page the
# renderer may discard — and it exempts this route from speculation without
# touching the rules that other routes still benefit from.
if request.headers["Sec-Purpose"] contains "prerender":
    return 503

Do not vary content on Sec-Purpose — a prefetched response is shown to the user verbatim on activation. Refuse or serve; never serve a degraded variant.


Verification workflow

  1. Open Chrome DevTools → Application panel → Speculative loads (under “Background services” in older versions). The Rules view lists every rule set the page registered, with JSON parse errors called out inline — a malformed rule fails silently everywhere else.
  2. Switch to the Speculations sub-view. Each candidate URL appears with a live status: Not triggered (waiting on eagerness), Running, Ready, or Failure with a reason string (“The prerendering navigation failed because…”, cap exceeded, disallowed API used).
  3. Trigger a moderate rule by hovering a matching link for ~200 ms and watch the status flip to Ready. In the Network panel the request row shows the lowest priority tier and the Sec-Purpose request header in its headers view.
  4. Click the link. The Speculative loads panel on the destination page reports “This page was successfully prerendered”; in the console, performance.getEntriesByType('navigation')[0].activationStart returns a positive timestamp.
  5. Confirm the perceived win with a PerformanceObserver:
// Verification: on an activated prerender, LCP is measured relative to
// activation, so a healthy rollout shows a bimodal field distribution —
// near-zero LCP for activated navigations, unchanged LCP for the rest.
new PerformanceObserver((list) => {
  const nav = performance.getEntriesByType('navigation')[0];
  for (const entry of list.getEntries()) {
    console.log('LCP:', Math.round(entry.startTime), 'ms',
      nav.activationStart > 0 ? '(prerender-activated)' : '(normal load)');
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

Lighthouse will not exercise speculation (it audits a single cold navigation), so treat lab numbers as the without-speculation baseline and measure the win in field data segmented by activationStart > 0.


Edge cases and gotchas

JavaScript that must not run twice — or too early

Prerendered pages execute scripts at fetch time, not view time. Anything with a per-view or per-user side effect — pageview beacons, A/B assignment writes, stock reservations, “seen” markers — must be deferred behind the whenActivated guard above. The symptom of getting this wrong is subtle: inflated pageviews and session counts in analytics, since a discarded prerender still ran the beacon code. Conversely, code that reads time (countdowns, “posted 2 minutes ago”) captures the prerender timestamp and is stale at activation; recompute on prerenderingchange.

Deferred behaviour inside the hidden renderer

The prerendered document is not a full peer of a visible page. Cross-origin iframes are not loaded until activation, so embedded widgets, consent frames, and ad slots appear only after the swap — an intentional privacy boundary, but one that surprises layout code measuring iframe heights during load. Also deferred or denied: permission prompts (auto-rejected), getUserMedia, audio/video playback, downloads, and window-opening. Some blocked API calls cause Chromium to cancel the prerender outright; the Speculative loads panel names the offending API in its failure reason.

Cross-origin and credential restrictions

Prerender is same-site territory. Same-origin targets work unconditionally; same-site cross-origin targets (another subdomain of the same registrable domain) require the destination to send Supports-Loading-Mode: credentialed-prerender; cross-site prerender is unsupported, full stop. Cross-site prefetch is permitted but privacy-constrained: the request is sent without credentials, and the browser skips it entirely if it already holds cookies for the destination site — so cross-site speculation mostly benefits logged-out landing traffic.

Cache semantics and reuse windows

A speculative prefetch is held in an in-memory store owned by the initiating document — it is not a durable HTTP-cache write, and it dies with the page or after roughly ten minutes. The response still flows through normal HTTP caching on its way in, so a cacheable response may additionally populate the disk cache, and origin caching interacts with speculation exactly as described in cache interaction and stale-while-revalidate. Two header-level levers matter: Cache-Control: no-store on the target does not block prefetch (the speculation store is not the HTTP cache), but a Set-Cookie change or no-store can cause a prerender to be discarded rather than activated; and No-Vary-Search paired with expects_no_vary_search lets a click on /list?utm_source=x reuse a speculation for /list.

Global disable conditions

Speculation is suppressed wholesale when the user enables Save-Data, on effectiveType values of slow-2g/2g, when Chrome’s “Preload pages” setting is off, and in low-memory device configurations. Build dashboards around activation rate, not rule presence — a rule that is correct in DevTools on your workstation may fire for only a fraction of field sessions.


FAQ

For full-document navigations in Chromium, effectively yes — rules are navigation-aware, dedupe against in-flight clicks, and unlock prerender. Classic prefetch keeps two jobs: warming individual subresources (the next route’s bundle or hero image, which speculation rules cannot express) and providing the only declarative speculation in Safari and Firefox. Most production setups run both, gated by HTMLScriptElement.supports('speculationrules').

How do I stop analytics from counting a prerendered page twice?

Gate the pageview beacon on document.prerendering. When true, defer to a { once: true } listener on prerenderingchange, which fires exactly once at activation — the moment the user actually sees the page. Normal loads see document.prerendering === false and fire immediately, so one guard covers both paths and discarded prerenders never emit a view.

What happens to a prerendered page the user never visits?

The hidden renderer is discarded: when the initiating page navigates away, when a newer candidate evicts it under the eagerness cap, under device memory pressure, or after roughly ten minutes. Discards are invisible to the user; the cost is the bandwidth the hidden load consumed plus the transient renderer process, which is exactly why prerender caps are 2–10 candidates rather than prefetch’s 50.

Can speculation rules prerender cross-origin pages?

Cross-site prerender: no. Same-site cross-origin prerender (say, app.example.com from www.example.com): only if the target responds with Supports-Loading-Mode: credentialed-prerender. Cross-site prefetch works, but is sent credential-less and is skipped when the browser already has cookies for that site, limiting it to first-contact traffic.

How do I detect whether a browser supports speculation rules?

HTMLScriptElement.supports('speculationrules') returns true in Chromium 105+. Engines without support ignore the script type entirely, so static rules degrade to nothing — detection matters chiefly when deciding whether to also run a fallback strategy, or when migrating off a JavaScript prefetch library that you may want to keep only for non-Chromium sessions.