Migrating from Quicklink to Speculation Rules

Your site prefetches next-page navigations with a quicklink-style library — an IntersectionObserver that injects <link rel="prefetch"> for every anchor entering the viewport — and you want the native, HTML-declarative replacement that costs zero main-thread JavaScript and can prerender instead of merely warming a cache.

Root cause: library prefetch is subresource prefetch with a runtime bill

A viewport-prefetch library operates entirely outside the browser’s navigation machinery. It observes anchors on the main thread, then injects <link rel="prefetch"> elements (or fires low-priority fetch() calls), producing what the browser sees as ordinary subresource prefetches: bytes deposited into the HTTP cache, keyed and revalidated like any other response. The browser does not know these responses are intended for navigation. It cannot adopt an in-flight library prefetch when the user actually clicks — a click mid-prefetch races and frequently double-fetches — and it applies no intent model beyond “the link was scrolled into view”, which on a link-dense listing page means dozens of speculative requests for links that will never be clicked.

The runtime cost is paid on every page. The library ships 1–2 KB of script plus your integration glue, registers an observer across every matching anchor, re-scans on DOM mutation in single-page apps, and executes callbacks on the main thread during scroll — precisely the moments interaction latency is most visible. It must also reimplement, in JavaScript, safety checks the platform already owns: navigator.connection.effectiveType sniffing, Save-Data checks, origin allowlists, and request throttling. Each is a copy of a browser responsibility, running later and with less information than the browser has. The dynamic hint injection approach shares this ceiling: injected hints always trail parser-discovered ones, and injected prefetches are always subresource-grade.

The Speculation Rules API moves the whole workload into the browser’s speculation engine. One JSON rule declares which links are navigation candidates and how much intent (eagerness) justifies spending bytes. The engine watches links natively (no observer, no mutation re-scans), dedupes speculative fetches against real navigations, enforces candidate caps and connection-quality suppression unconditionally, and — the capability no library can reach — offers prerender, which loads, executes, and lays out the destination in a hidden renderer so activation is near-instant rather than merely TTFB-free. The full rule syntax, eagerness matrix, and prerender lifecycle are covered in the section guide on speculation rules prefetch and prerender; this page covers only the migration.

Before/after architecture

Architecture comparison: quicklink IntersectionObserver pipeline on the main thread versus a single declarative speculation rule handled by the browser engine Two horizontal pipelines. Before: quicklink runs an IntersectionObserver on the main thread, injects link rel=prefetch tags, and warms the HTTP subresource cache with no navigation awareness. After: one speculationrules JSON block feeds the browser speculation engine, which watches intent natively off the main thread and produces navigation-aware prefetches or prerenders that the real click adopts or activates. BEFORE — library prefetch (main thread) quicklink.js ~2 KB + listeners IntersectionObserver every in-viewport <a>, re-scan on DOM mutation Inject prefetch <link rel=prefetch> per visible link HTTP cache subresource entry; click may re-fetch scroll-time main-thread callbacks · no intent model · no dedupe with the real navigation AFTER — speculation rules (browser engine) One JSON rule <script type=speculationrules> zero runtime JS Speculation engine native intent watch (hover / pointerdown), caps, guards Prefetch / prerender navigation-grade store, optional hidden renderer Click activates or adopts off-main-thread intent detection · FIFO candidate caps · in-flight speculation adopted by the navigation Same goal — a faster next navigation — with the library, the observer and the guesswork deleted.

Migration mapping: library option → rule equivalent

Quicklink-style option Speculation rules equivalent
Viewport entry trigger (IntersectionObserver) Document rule with eagerness: "eager" (intent-based) — or "moderate" for hover/pointerdown, which converts better per byte
el: container scoping where: { selector_matches: "main a" }
origins: allowlist Same-origin is the default candidate scope; widen deliberately with href_matches full-URL patterns
ignores: regex/function list not conditions: { "not": { "href_matches": "/logout*" } }, { "not": { "selector_matches": ".no-speculate" } }
limit: / throttle: concurrency Built-in caps: 2 live candidates (FIFO) at moderate/conservative, 50 prefetch / 10 prerender at eager/immediate
navigator.connection / Save-Data guards Enforced unconditionally by the engine; delete the code
priority: true (high-priority fetch()) No equivalent needed — upgrade the action from prefetch to prerender for high-value links instead
Hover-delay plugins / delay: eagerness: "moderate" (~200 ms hover) or "conservative" (pointerdown)
Manual prefetch(url) calls for known URLs List rule: { "urls": ["/checkout/"], "eagerness": "immediate" }

Read down the table and the four outcomes it produces are the four terminal states of one decision: exclude, list-rule, prerender, or the default document prefetch. Most link classes fall through to the last row.

Decision tree turning the option mapping table into one rule choice per link class: exclude, list rule, prerender, or the default moderate document prefetch Three stacked decision diamonds with a yes branch to the right of each. First, is the link state-changing or private such as logout, cart or a tracked redirect: yes means exclude it with a not condition. Second, is there exactly one known next URL such as a checkout funnel step: yes means a list rule at immediate eagerness. Third, is it a top navigation target whose analytics are gated on document.prerendering: yes means prerender at moderate eagerness. Everything that answers no three times lands in the default document prefetch rule at moderate eagerness. Which rule does a link class get? — the mapping table as one decision path State-changing or private? logout, cart, tracked redirect One known next URL? checkout funnel, wizard step Top navigation target? analytics gated on prerendering yes yes yes no no no Exclude — never speculate { "not": { "href_matches": "/logout*" } } List rule — eagerness: immediate { "urls": ["/checkout/"] } Prerender — eagerness: moderate LCP 1,640 ms → 120 ms on those clicks Default — prefetch, eagerness: moderate { "where": { "selector_matches": "main a" } }

Minimal reproduction: before and after

Before — the library pattern being replaced (observer wiring reduced to its essentials):

// BEFORE: viewport-based library prefetch.
// Scheduling rationale (the library's, not the browser's): any link the user
// can see might be clicked, so prefetch it — main-thread callbacks fire during
// scroll, and every injected hint is a navigation-blind subresource prefetch.
const seen = new Set();
const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting || seen.has(entry.target.href)) continue;
    seen.add(entry.target.href);
    if (/\/(logout|cart)\b/.test(entry.target.pathname)) continue; // hand-rolled ignore list
    const hint = document.createElement('link');
    hint.rel = 'prefetch';
    hint.href = entry.target.href;
    document.head.appendChild(hint); // HTTP-cache warm only; a click cannot adopt it
  }
});
document.querySelectorAll('main a[href^="/"]').forEach((a) => io.observe(a));

After — the entire replacement is one static block; no observer, no listeners, no scroll-time work:

{
  "prefetch": [
    {
      "where": {
        "and": [
          { "selector_matches": "main a" },
          { "not": { "href_matches": "/logout*" } },
          { "not": { "href_matches": "/cart/*" } }
        ]
      },
      "eagerness": "moderate"
    }
  ]
}

Embedded as <script type="speculationrules"> in the page <head>, moderate eagerness trades the library’s “everything visible” model for “hovered ~200 ms or pressed” — fewer speculative requests, far higher click-through per prefetched byte, and every fetch is navigation-grade: the click adopts it mid-flight instead of racing it. The hover threshold is a Chromium heuristic rather than a number you configure, so if the trade it strikes is wrong for your click patterns the lever is the eagerness keyword itself — the four settings and what each costs are compared in tuning speculation rules eagerness settings.

Timeline of one moderate-eagerness speculation from pointerover to click, showing the 200 millisecond hover wait, the 210 millisecond prefetch, and the click adopting the stored response Four lanes on a shared millisecond axis. The pointer rests on the link from zero to 520 milliseconds. The speculation engine waits out the 200 millisecond moderate hover threshold, then holds the candidate Ready in one of two FIFO slots. The network runs a prefetch GET carrying Sec-Purpose colon prefetch from 205 to 415 milliseconds, costing 210 milliseconds of TTFB. The response sits in the prefetch store until the click at 520 milliseconds adopts it, so the navigation pays roughly zero TTFB instead of 210. Pointer Speculation engine Network Prefetch store pointer held over /article/ 200 ms hover wait candidate Ready · 1 of 2 FIFO slots prefetch GET · 210 ms Sec-Purpose: prefetch held in store click · t = 520 ms 0 100 200 300 400 500 milliseconds since pointerover The click at 520 ms adopts the stored response — TTFB ~0 ms instead of 210 ms. A library prefetch is navigation-blind: a click mid-flight races it and the browser re-fetches.

Migration protocol

  • [ ] 1. Baseline the current behaviour. With the library still active, record a browsing session in the DevTools Network panel. Note prefetch request count per page, total speculative bytes, and how many prefetched URLs were actually navigated to. Record scroll-interaction main-thread time in the Performance panel for the same session.
  • [ ] 2. Translate the config using the mapping table. Convert el scoping to selector_matches, the ignores list to not conditions, and drop every connection-quality guard — the engine owns those now. Start with prefetch + moderate even if you plan to prerender later.
  • [ ] 3. Ship the rule alongside the library, gated apart. Add the speculationrules block, and wrap the library bootstrap in if (!HTMLScriptElement.supports?.('speculationrules')) so exactly one mechanism runs per browser. Verify no page loads both: in Chromium the library must not initialise.
  • [ ] 4. Verify speculation in DevTools. Open Application → Speculative loads. Confirm the rule set parses without errors and candidates flip from Not triggered to Ready on a ~200 ms hover. Click through and confirm the destination page reports the speculation was used and the Network row carries Sec-Purpose: prefetch.
  • [ ] 5. Confirm the ignore list holds. Hover every excluded URL class (logout, cart mutations, tracked redirects) and confirm no speculation appears for them. A missed exclusion is the one genuinely dangerous migration failure.
  • [ ] 6. Remove the library from Chromium-dominant bundles. If non-Chromium traffic does not justify the fallback, delete the dependency, its bootstrap, and its config; otherwise leave the gated fallback and add a removal date. Confirm the bundle diff shows the library gone. If the prefetcher is your framework’s router rather than a standalone dependency — NuxtLink’s prefetch behaviour, for example — switch it off at the router level instead, or the framework will keep injecting hints the engine now duplicates.
  • [ ] 7. Escalate proven paths to prerender. After a week of clean prefetch data, add a prerender rule scoped by selector_matches to your top navigation targets at moderate eagerness — first confirming those templates gate analytics on document.prerendering, as covered in the section guide.
  • [ ] 8. Re-measure against the step-1 baseline. Compare speculative request count, wasted-byte ratio (speculated but never visited), scroll-time main-thread cost, and navigation TTFB/LCP for speculated clicks.

Before/after metrics

Content site, 40-link listing pages, Chromium field data on simulated 4G; “before” is viewport-based library prefetch, “after” is the moderate document rule from this page plus prerender on headline links.

Metric Library prefetch Speculation rules Change
Speculative requests per listing page 34 2.3 −93%
Speculated-but-unvisited bytes per session 1.9 MB 0.14 MB −93%
Prefetch hit rate (speculated → clicked) 6% 61% +55 pts
Scroll-interaction main-thread time (library callbacks) 41 ms 0 ms −41 ms
Shipped JS for prefetching 2.1 KB 0 KB −2.1 KB
TTFB on speculated navigation 210 ms ~0 ms (served from store) −210 ms
LCP on prerendered headline clicks (p75) 1,640 ms 120 ms −93%

The headline is not the request count — it is that the same or better navigation speedup arrives with an order of magnitude less waste, because eagerness models intent where the viewport models only visibility. The prerender row has no library-side counterpart at any cost: rendering ahead of the click is simply outside what cache-warming can do.

FAQ

Only when non-Chromium traffic share justifies the runtime cost you just removed. If you keep it, gate it on !HTMLScriptElement.supports('speculationrules') so the two mechanisms never coexist in one session. Many teams drop the fallback entirely: in WebKit and Gecko the library only warms the HTTP cache, a modest win that must outweigh shipping and executing the script on every page.

By design. The viewport model speculates on visibility — most in-viewport links are never clicked. A moderate document rule speculates on hover/pointerdown and Chromium holds at most two live candidates (FIFO eviction), so bytes follow demonstrated intent. Expect total speculative requests to collapse while hit rate per prefetched byte rises; if you genuinely want visibility-breadth back, eagerness: "eager" restores it with the engine’s 50-candidate cap still applied.

Yes, unconditionally and with zero code. Chromium disables speculative loading under Save-Data, on slow-2g/2g effective connection types, and when the user turns off page preloading in settings. The navigator.connection guards you wrote for the library are exactly the code the migration lets you delete.


Related