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

Quicklink pipeline vs speculation rules pipeline 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's speculation engine, which watches intent natively off the main thread and produces navigation-aware prefetches or prerenders that dedupe with the real click. BEFORE — library prefetch (main thread) quicklink.js ~2 KB script + 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, Save-Data Prefetch / prerender navigation-grade store, optional hidden renderer Click activates or adopts fetch off-main-thread intent detection · FIFO candidate caps · in-flight speculation adopted by the navigation Same declared goal — faster next navigation — but the after pipeline deletes the library, the observer, and the guesswork.

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" }

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.

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.
  • [ ] 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