Instant Page Navigations with the Speculation Rules API

In the Core Web Vitals guide we covered how to fix each metric individually. But what if a single technique could improve LCP, INP, and CLS all at once?

That is exactly what the Speculation Rules API does. You tell the browser which page the user is likely to visit next, and it fetches — or even fully renders — that page ahead of time. When the user clicks, the page doesn’t load. It is already loaded. LCP drops to near zero, load-time layout shifts happen before the user ever sees the page, and INP improves because JavaScript finished executing before the interaction.

This is the most under-discussed high-impact performance win of the 2024–2026 era. And it adds zero bytes to your JavaScript bundle.

What Is Speculative Loading?

The idea isn’t new. <link rel="prefetch"> has existed for years. What’s new is an expressive syntax that lets you tell the browser which pages to speculatively load and when.

The Speculation Rules API offers two levels:

LevelWhat It DoesCostPayoff
prefetchDownloads only the HTML response body, no subresourcesLow — a single GET requestNoticeably faster
prerenderFully loads and renders the page in an invisible tab, JavaScript runsHigh — about the same as an <iframe>Near-instant

The distinction matters. Adopt prefetch broadly — the risk is low and the gain is real. Adopt prerender sparingly — only when the user is highly likely to visit that page. Every prerender the user doesn’t navigate to is wasted memory and bandwidth.

Note: Prerendered URLs are prefetched too. You don’t need to declare both for the same URL.

Basic Usage: URL Lists

The simplest form is a <script type="speculationrules"> element on the page. Its contents are JSON, not executable JavaScript.

<script type="speculationrules">
  {
    "prerender": [{ "urls": ["/next-post", "/about"] }]
  }
</script>

This works well when the next step is obvious: a multi-step form flow, the “next page” link in a paginated archive, or a news site’s latest article.

But if a blog homepage has dozens of links, listing them all is neither practical nor efficient. That’s what document rules are for.

Document Rules: The where Syntax

Document rules match links in the page by URL pattern or CSS selector. Instead of maintaining a static list, you describe a policy to the browser.

<script type="speculationrules">
  {
    "prerender": [
      {
        "where": {
          "and": [
            { "href_matches": "/*" },
            { "not": { "href_matches": "/logout" } },
            { "not": { "href_matches": "/*\?*(^|&)add-to-cart=*" } },
            { "not": { "selector_matches": ".no-prerender" } },
            { "not": { "selector_matches": "[rel~=nofollow]" } }
          ]
        },
        "eagerness": "moderate"
      }
    ]
  }
</script>

Every line here earns its place:

  • href_matches: "/*" — same-origin links only. Uses URL Pattern API syntax.
  • Logout and “add to cart” URLs are excluded. This isn’t a detail, it’s mandatory — we’ll see why shortly.
  • The .no-prerender class gives you a manual escape hatch on individual links.
  • [rel~=nofollow] typically marks untrusted or user-submitted links.

Beyond and, not, and href_matches

The where block can nest, and or is supported. To prerender only blog posts, for example:

<script type="speculationrules">
  {
    "prerender": [
      {
        "where": {
          "or": [{ "href_matches": "/posts/*" }, { "href_matches": "/projects/*" }]
        },
        "eagerness": "moderate"
      }
    ]
  }
</script>

Eagerness: When Does Speculation Fire?

The eagerness setting is where you balance gain against wasted resources. There are four values:

ValueWhen It FiresWhere to Use It
immediateAs soon as the rules are observedShort URL lists, high-confidence cases
eager10ms hover on desktop; 50ms after the link enters the viewport on mobileLightweight, static sites
moderate200ms hover or pointerdown on desktop; viewport heuristics on mobileThe best starting point for most sites
conservativeOn pointerdown or touch downHeavy pages, constrained resources

The defaults deserve attention: list rules default to immediate, while document rules default to conservative. So if you write a document rule and omit eagerness, speculation only fires once the user starts clicking — and you lose most of the benefit.

For most blogs and content sites, this is the right starting point:

<script type="speculationrules">
  {
    "prerender": [{ "where": { "href_matches": "/*" }, "eagerness": "moderate" }]
  }
</script>

A 200 millisecond hover is a surprisingly strong signal of intent, and it usually leaves enough time to prepare the page before the click lands.

Chrome’s Limits

Chrome caps usage to prevent abuse:

EagernessPrefetchPrerender
immediate5010
eager / moderate / conservative2 (FIFO)2 (FIFO)

Interaction-driven settings work first-in-first-out: once the limit is reached, the oldest speculation is cancelled. A cancelled speculation isn’t entirely wasted — cacheable resources remain in the HTTP cache.

Chrome also skips speculation entirely when:

  • Save-Data mode is enabled
  • Energy saver is on and the battery is low
  • The device is memory-constrained
  • The “Preload pages” setting is turned off
  • Pages are opened in background tabs

This makes the API respectful of user preferences by design. A hand-rolled prefetch built on fetch() does none of this.

Delivering Rules via HTTP Header

Instead of embedding rules in HTML, you can serve them with an HTTP header. This is useful for CDN-level deployment or for managing rules site-wide from one place.

Speculation-Rules: "/speculationrules.json"

The JSON file must be served with the correct MIME type:

Content-Type: application/speculationrules+json

If you use relative URLs, add the "relative_to": "document" key. Otherwise relative URLs resolve against the JSON file’s location, not the document.

You can use an inline script and the HTTP header simultaneously; all rules are merged.

Configuring It on Vercel

This blog runs on Vercel. You can add the header through vercel.json:

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [{ "key": "Speculation-Rules", "value": ""/speculationrules.json"" }]
    },
    {
      "source": "/speculationrules.json",
      "headers": [{ "key": "Content-Type", "value": "application/speculationrules+json" }]
    }
  ]
}

The Critical Part: When Is It Unsafe?

This is the section most guides gloss over, and the one that burns you in production. Speculative loading sends a real GET request to your server. If that request has a side effect, the side effect happens even if the user never clicks.

These URLs are not safe to prefetch:

  • Sign-out URLs — the user gets logged out without knowing
  • Language switching URLs
  • “Add to cart” URLs
  • Sign-in flow URLs that send a one-time password (OTP)
  • URLs that consume a usage allowance, like a monthly free-article quota
  • URLs that trigger server-side ad conversion tracking

Prerender carries even more risk because the page’s JavaScript runs too. On top of the above, watch for:

  • Pages that modify localStorage or IndexedDB on load — this can corrupt another tab the user is currently looking at
  • Pages that send analytics events or record ad impressions — your reports get inflated

Rule of thumb: If a URL is disallowed in your robots.txt or only reachable by authenticated users, think twice about whether it is safe to speculate.

Server-Side Detection: Sec-Purpose

Speculative requests arrive with the Sec-Purpose header:

Sec-Purpose: prefetch              ← a prefetch request
Sec-Purpose: prefetch;prerender    ← a prerender request

Your server can act on it. In SvelteKit, via a handle hook:

// src/hooks.server.js
export async function handle({ event, resolve }) {
  const secPurpose = event.request.headers.get("sec-purpose") ?? "";
  const isSpeculative = secPurpose.includes("prefetch");

  // Don't increment server-side counters on speculative requests
  if (!isSpeculative) {
    await recordVisit(event.url.pathname);
  }

  // Block speculative loading of this route entirely
  if (isSpeculative && event.url.pathname.startsWith("/logout")) {
    return new Response(null, { status: 503 });
  }

  return resolve(event);
}

Returning a non-2XX status cancels the speculation. But treat that as a last resort. The better approach is to allow speculation and defer the side effect until the page is actually viewed, using JavaScript.

Client-Side Detection: document.prerendering

A prerendered page starts with document.prerendering === true. When the user actually navigates to it, the prerenderingchange event fires.

Wrapping this in a promise is the cleanest pattern:

// A promise that resolves when the page is activated
const whenActivated = new Promise((resolve) => {
  if (document.prerendering) {
    document.addEventListener("prerenderingchange", resolve, { once: true });
  } else {
    resolve();
  }
});

async function initAnalytics() {
  await whenActivated;
  // Initialise analytics here
}

initAnalytics();

You can also detect after the fact whether a page was prerendered:

function pagePrerendered() {
  return document.prerendering || self.performance?.getEntriesByType?.("navigation")[0]?.activationStart > 0;
}

activationStart gives the time between when the prerender started and when the document was activated. A non-zero value means the page was prerendered. To test it quickly in the DevTools console:

performance.getEntriesByType("navigation")[0].activationStart;

Using It Alongside SvelteKit

SvelteKit already has a preloading mechanism. This blog’s app.html contains this line:

<body data-sveltekit-preload-data="hover"></body>

The two techniques are complementary, not competing. Understanding the difference matters:

Aspectdata-sveltekit-preload-dataSpeculation Rules API
What it fetchesThe route’s JS module and load dataThe full HTML document and subresources
Who runs itThe SvelteKit router (client navigation)The browser itself
ScopeIn-app soft navigationFull page navigation
JS costPart of the SvelteKit runtimeZero
User preferencesNoRespects Save-Data, battery, memory

SvelteKit’s preload is fast once you’re inside the app. Speculation Rules kicks in when the user arrives from outside or does a full page load. Run both.

To add the rules, the simplest place is src/app.html, since the markup is static and the browser sees it in the initial HTML:

<body data-sveltekit-preload-data="hover">
  <script type="speculationrules">
    {
      "prerender": [
        {
          "where": {
            "and": [{ "href_matches": "/*" }, { "not": { "selector_matches": "[rel~=nofollow]" } }]
          },
          "eagerness": "moderate"
        }
      ]
    }
  </script>
  <div style="display: contents">%sveltekit.body%</div>
</body>

If you need the rules to differ per route, inject them from a +layout.svelte with onMount instead, using document.createElement as shown in the next section.

Careful: Inserting a speculation rules script via innerHTML does not work, for security reasons. Neither does adding it by hand in the DevTools Elements panel. If you insert rules dynamically, use document.createElement.

Dynamic Insertion and Feature Detection

Speculation Rules is not Baseline yet — MDN marks it “limited availability” and in practice it works in Chromium-based browsers. You can use the modern API where supported and fall back to <link rel="prefetch"> elsewhere:

if (HTMLScriptElement.supports?.("speculationrules")) {
  const script = document.createElement("script");
  script.type = "speculationrules";
  script.textContent = JSON.stringify({
    prerender: [{ where: { href_matches: "/*" }, eagerness: "moderate" }],
  });
  document.body.append(script);
} else {
  const link = document.createElement("link");
  link.rel = "prefetch";
  link.href = "/next-post";
  document.head.append(link);
}

Nothing breaks in browsers that don’t support it. Treat Speculation Rules as a progressive enhancement: supporting users get instant navigation, everyone else gets the usual experience.

The Effect on Core Web Vitals

When a fully prerendered page is activated, Chrome measures metrics relative to activation time, not to when the prerender started. The result:

  • LCP drops to near zero — the largest element rendered before the user ever saw the page
  • CLS improves — load-time layout shifts happen in the invisible tab
  • INP improves — JavaScript finished executing before the user interacted

This flows into the Chrome User Experience Report (CrUX), which means it directly affects the field data Google uses for ranking.

Version 3.1.0 and later of the web-vitals library handles prerendered navigations the same way Chrome does, and flags them in the Metric.navigationType attribute. Make sure you’re on that version when measuring.

Measure Your Prerender Rate

Send the share of prerendered navigations to your analytics as a custom dimension. It’s the only way to see whether the investment pays off:

sendToAnalytics({
  event: "page_view",
  wasPrerendered: pagePrerendered(),
});

Common Pitfalls

1. Stale content

Chrome caches prefetched pages for about 5 minutes. Users may see content up to 5 minutes out of date. Account for this on fast-changing pages (live scores, stock levels, comment threads). If needed, clear the cache with the Clear-Site-Data header:

Clear-Site-Data: "prefetchCache", "prerenderCache"

You can return this header on any state-changing same-site request — for example an /api/add-to-cart call.

2. User-specific state mismatch

If a page was prerendered in a logged-out state while the user signs in from another tab, they’ll see themselves logged out when they navigate to it. The fix is for pages to refresh themselves. The Broadcast Channel API is ideal for this.

3. Content Security Policy

Because speculation rules use a <script> element, you need to allow them in your script-src directive if you enforce CSP. Use the 'inline-speculation-rules' source, a hash, or a nonce. Sites with a strict CSP must inject rules via JavaScript.

4. It doesn’t work for SPAs

Speculation Rules only applies to full page navigations managed by the browser. Route changes inside a single-page app cannot be prerendered. You can, however, prerender the SPA itself from a previous page to offset its initial load cost.

5. UTM parameters fragment the cache

If ?utm_content=123 and ?utm_content=456 return the same page from your server, tell the browser with No-Vary-Search:

<script type="speculationrules">
  {
    "prefetch": [{ "urls": ["/products"], "expects_no_vary_search": "params=("id")" }]
  }
</script>

Implementation Checklist

Roll it out gradually and safely, in this order:

  1. Start with prefetch. Low risk, real gain. Write a document rule with moderate eagerness.
  2. Exclude unsafe URLs. Add logout, language switching, cart, OTP, and quota-consuming routes to a not block.
  3. Protect your analytics. Add the document.prerendering check, or your page view counts will inflate.
  4. Verify in DevTools. The Application → Speculative loads panel shows which rules fired and why any were cancelled.
  5. Measure. Send activationStart to your analytics and track your hit rate.
  6. Then move to prerender. Only for routes with a high hit rate, and still with moderate.

Frequently Asked Questions

Which browsers support the Speculation Rules API?

Chrome has supported prerender since version 109, and the eagerness field since 121. Chromium-based browsers (Edge, Opera, Brave) support it too. Safari and Firefox do not yet, which is why MDN marks it as not Baseline. Where it isn’t supported nothing breaks — the page simply loads normally.

Should I use prefetch or prerender?

Start with prefetch. It costs one GET request and can be applied broadly. Prerender costs roughly as much as rendering an <iframe>; use it only when the user is genuinely likely to visit that page.

Does prerender waste the user’s data?

If the user doesn’t navigate there, yes. But Chrome skips speculation in Save-Data mode, under energy saver, on low-memory devices, and when the user turns off “Preload pages”. Still, choose your eagerness deliberately — moderate is the right balance in most cases, not immediate.

Will prerender break my analytics?

Yes, unless you handle it — you’ll record views for pages the user never saw. The fix is to defer analytics using the document.prerendering check or the prerenderingchange event. Some providers like Google Analytics and NewRelic are prerender-aware, but verify your own code.

Do I need this if I already have data-sveltekit-preload-data?

Yes, they do different things. SvelteKit’s preload fetches the route module and load data for in-app soft navigations. Speculation Rules targets full document navigation at the browser level and respects the user’s data and battery preferences. Use both.

Will speculation rules actually improve my Core Web Vitals score?

Yes. Chrome measures prerendered pages relative to activation, which typically means a near-zero LCP. Those values land in CrUX, which is the field data Google uses for ranking. But it only applies to navigations that were actually prerendered, so you need to measure your hit rate.

How is this different from <link rel="prerender">?

<link rel="prerender"> was never standardized, was Chrome-only, and has since been reduced to NoState Prefetch behavior. Speculation Rules also fetches subresources loaded via JavaScript, isn’t blocked by Cache-Control settings, and behaves as a hint the browser may decline.

Conclusion

The Speculation Rules API offers something unusual in performance work: a large win that doesn’t grow your JavaScript bundle or change your architecture. You write a block of JSON and the browser handles the rest.

But it isn’t free. The price is attention: knowing which URLs carry side effects, preparing your analytics for prerendering, and choosing an eagerness that respects your users’ resources.

Start with a prefetch rule at moderate eagerness. Exclude logout and state-changing routes. Protect your analytics. Measure for a week. If the numbers convince you, graduate to prerender.

Every page where your user never starts waiting is a user you keep.

entr