WP Manifestindependent plugin directory
manifest / performance / intent-prefetch

Intent Prefetch

Prefetch same-origin WordPress pages on hover or touch intent, so navigation feels instant. Vanilla JS, no libraries, no tracking.

★ 0stars

Install

Download source zip No release zip yet

This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/shaunpalmer/intent-prefetch/archive/refs/heads/main.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

Improve perceived WordPress navigation speed by prefetching the internal page a visitor is about to click.

Intent Prefetch watches for visitor intent on internal links — hovering over a link, or touching one on mobile — and issues a single, deduplicated, concurrency-capped, low-priority same-origin request so the next navigation is served from the browser cache. It is a clean-room, deliberately small WordPress plugin written in the WordPress way.

  • PHP: minimal, WordPress-native (enqueue + Settings APIs, nonces, capability checks).
  • Front-end: one vanilla JavaScript file. No jQuery, no libraries, no build step.
  • No tracking, no telemetry, no hidden calls, no database tables, no core edits, no WooCommerce dependency.

1. Installation

  1. Copy the intent-prefetch/ folder to wp-content/plugins/ — or upload the zip via Plugins → Add New → Upload Plugin.
  2. Activate Intent Prefetch on the Plugins screen.
  3. Open Settings → Intent Prefetch (optional — safe defaults are already active).

To build an installable zip from the source folder:

cd <parent-of-intent-prefetch>
zip -r intent-prefetch.zip intent-prefetch -x "intent-prefetch/PLANNING/*"

(If you already have a zip, its structure must be intent-prefetch/intent-prefetch.php.)

Requirements: WordPress ≥ 6.3, PHP ≥ 7.4. Tested on PHP 8.3. (6.3 is the first WordPress whose Scripts API emits the defer attribute from script strategy data.)


2. Usage

Activate the plugin and browse any public page. Open the browser Network tab, then hover over an internal link for the configured delay (default 100 ms): you will see one fetch for that page. Click the link — navigation feels faster because the page is already cached.

Per-link opt-out (useful in themes and page builders without touching code):

<a href="/about/" data-intent-prefetch="off">About</a>

Links inside any ancestor with the attribute, or with the class .no-prefetch, are also skipped. Links inside #wpadminbar are always skipped.

Settings

Setting Default Meaning
Enable prefetching On Master switch for the front-end behaviour
Intent delay (ms) 100 How long the pointer must rest on a link before prefetching
Max concurrent prefetches 2 Simultaneous in-flight requests (1–8)
Prefetch on mobile / touch On Fires on pointerdown/touch (there is no hover on touch)
Respect Save-Data On Never prefetch when the browser reports Save-Data mode
Skip slow networks On Never prefetch on slow-2g / 2g connections
URL / path exclusions (empty) Extra substrings never prefetched, one per line

Code-level control:

// Disable everywhere except the blog:
add_filter( 'intent_prefetch_settings', function ( $s ) {
    $s['enabled'] = is_home();
    return $s;
} );

// Add code-level exclusions:
add_filter( 'intent_prefetch_extra_exclusions', function ( $list ) {
    $list[] = '/members/';
    return $list;
} );

// Turn the public script off completely on a page:
add_filter( 'intent_prefetch_disable', function () {
    return is_page( 'contact' );
} );

3. What gets prefetched (eligibility rules)

A link is prefetched only when all of these hold:

  • It is an <a> with a real href (not an empty or #-only link).
  • The scheme is http: or https: (never mailto:, tel:, javascript:, data:, …).
  • It is same-origin: matches the current page origin or the site's canonical origin (covers www/non-www aliases). External domains are never prefetched.
  • It is a different page (same-path anchors / identical URL are skipped).
  • Its query string contains no state-changing parameter (action, add-to-cart/add_to_cart, remove_item, wc-ajax, rest_route, doing_wp_cron, _wpnonce, nonce, …).
  • Its path does not end in a typical binary/download extension and it has no download attribute.
  • It is not a form-submit-style link or a disabled link.
  • Its full URL does not contain any core or configured exclusion substring.
  • Network conditions permit it (Save-Data and slow-connection toggles).

Core exclusions (always enforced, not shown on the settings page):

/wp-admin            /wp-login.php        /wp-json         /wp-cron.php
/xmlrpc.php          /wp-comments-post.php
/feed                feed= (?feed=rss2/atom)               /trackback
comment-page-
action=              add-to-cart=         add_to_cart=     remove_item=
wc-ajax=             rest_route=          doing_wp_cron=   _wpnonce=
nonce=

These query-marker exclusions are also enforced structurally inside the JS engine, so they hold even if the settings list is empty or cleared.

A link or ancestor marked data-intent-prefetch="off" (or .no-prefetch) is always skipped.


4. Behaviour details

  • Hover intent: entering a link with the pointer starts a timer for the configured delay. Leaving the link cancels the timer, so fast mouse passes cost nothing.
  • Click/touch intent: pointerdown (or legacy touchstart) prefetches immediately — this is how touch devices get the benefit, since they have no hover.
  • Dedupe: every canonical URL is fetched at most once per page view.
  • Concurrency: no more than max_concurrent requests run at once. When the cap is reached, the newest hovered/tapped link is remembered and starts as soon as a slot frees (one pending slot, newest wins — never an unbounded queue).
  • Cache warming only: the response body is consumed and discarded — its scripts are never executed, no layout/JS is triggered, nothing is "rendered" ahead of time.
  • No redirect following: requests are issued with redirect: 'error', so if the server answers with a redirect the prefetch stops there. The final destination never receives a prefetch request, so redirect targets cannot bypass the exclusion rules.
  • Silent failure: every network call is wrapped; failures never surface to the page or the console.
  • Zero visual footprint: no CSS, no injected markup, no layout shift.

Why fetch() and not <link rel="prefetch">?

<link rel="prefetch"> is inconsistent for whole-document prefetch across engines and gives no way to enforce the concurrency cap, observe completion, or control caching flags. A same-origin fetch(…, { priority: 'low', cache: 'default', credentials: 'same-origin' }) works uniformly in every modern browser, lets the engine cap and dedupe, and warms the HTTP cache the same way a navigation would. (See PLANNING/RESEARCH-NOTES.md in the source repository for the option-by-option comparison, including the newer Speculation Rules API — Chromium-focused and parked as future work.)


5. Architecture

WordPress page load
   │  wp_enqueue_scripts
   ▼
intent-prefetch.php  ── header, constants, boot
   └── includes/class-intent-prefetch.php      hooks, settings contract, public asset + config
         └── assets/js/intent-prefetch.js      pointer-intent engine (fetch-based prefetch)
   └── admin/class-intent-prefetch-admin.php   Settings API page, sanitise
         └── admin/views/settings-page.php     escaped view
   └── uninstall.php                           deletes the one option
  • Root file (intent-prefetch.php): WordPress plugin header, constants, guards, boot. Thin.
  • Core class (Intent_Prefetch): owns the option contract, defaults, code-level exclusions, enqueues the deferred public asset, and exposes the config object to JS. No business logic in the root file.
  • Admin class (Intent_Prefetch_Admin): Settings → Intent Prefetch via the WordPress Settings API (register_setting, sections, fields), manage_options capability, nonces handled by the API, sanitisation for every field, and a Settings shortcut on the Plugins row.
  • View (settings-page.php): escaping-only template.
  • Asset (assets/js/intent-prefetch.js): the whole front-end behaviour, one IIFE, no dependencies.

Dependency direction: root → core class → { WordPress APIs, JS asset } and root → admin class → Settings API → view. No layer writes SQL. No layer makes external calls.


6. Security notes

  • No remote code execution. PHP never executes strings; the front-end only runs the bundled static script.
  • No hidden network calls. The only request a visitor's browser can make is a same-origin prefetch of a link they deliberately hovered or tapped. There is no beacon, no admin-ajax, no REST, no cron.
  • No state changes before a click. URLs whose query string carries state-changing parameters (action=, add-to-cart=, _wpnonce=, rest_route=, wc-ajax=, doing_wp_cron=, …) are never prefetched, in both the JS engine and the PHP core-exclusion list. Prefetch requests are issued with redirect: 'error', so a redirect target is never fetched either — the plugin only ever requests the exact URL the visitor hovered.
  • No telemetry. Nothing is logged, transmitted, or reported.
  • No dynamic PHP. No eval, no create_function, no call_user_func on user input.
  • No writes outside config. The only persistence is one wp_options row (intent_prefetch_settings); uninstall.php deletes exactly that. The sanitizer is idempotent, so WordPress double-sanitisation on first save cannot wipe configured exclusions.
  • No obfuscation. Source is plain and commented.
  • No unexpected admin privileges. Settings require manage_options; saves are protected by the Settings API nonce; every admin output is escaped (esc_html, esc_attr, esc_textarea, checked()), every input is sanitised (absint, sanitize_text_field, clamping).
  • Direct-access guards (defined( 'ABSPATH' ) || exit;) on every PHP file.
  • Never executes fetched pages. Prefetch responses are consumed and discarded, so a compromised or malicious same-origin page cannot run scripts inside the current page.
  • Same-origin only keeps referrers and credentials from leaking to third parties; the prefetched URL is always one the site itself would serve.

7. Test checklist

Run on a stock WordPress test site (e.g. Twenty Twenty-Four) with a couple of internal pages and one external link. Keep DevTools open on the Network tab.

Activation & admin

  • [ ] Plugin activates with no PHP warnings/notices (WP_DEBUG on).
  • [ ] Settings → Intent Prefetch renders; fields show defaults.
  • [ ] Saving settings persists across reloads.
  • [ ] A user without manage_options cannot save (capability enforced).
  • [ ] Settings link appears on the Plugins row.

Core behaviour

  • [ ] Hovering an internal link fires exactly one request after the configured delay.
  • [ ] Hovering the same link again fires no second request (dedupe).
  • [ ] Hovering and leaving quickly (before the delay) fires no request.
  • [ ] pointerdown (mouse click press / touch tap) fires immediately.
  • [ ] Increasing the delay to 1000 ms visibly moves when the request fires.

Skips (hover each and confirm zero requests)

  • [ ] External-domain link.
  • [ ] mailto:, tel:, javascript: links.
  • [ ] Anchor-only (#section) and same-page links.
  • [ ] /wp-admin and /wp-login.php links (e.g. an Edit link while logged in).
  • [ ] A link with download attribute and a link to a .pdf/.zip file.
  • [ ] A link inside #wpadminbar.
  • [ ] A link with data-intent-prefetch="off".
  • [ ] Any URL containing a configured exclusion substring (e.g. /cart/).
  • [ ] ?add-to-cart=123, ?action=delete&_wpnonce=…, ?rest_route=/wp/v2/posts (state-changing query params).
  • [ ] A link whose server responds with a redirect: no request reaches the redirect target.

Network & concurrency

  • [ ] With concurrency = 1, hovering two links quickly never exceeds one in-flight request.
  • [ ] With concurrency = 2, at most two are in flight.
  • [ ] Under DevTools "Network conditions → Save-Data: on", no requests fire (toggle on).
  • [ ] Under throttling to Slow 2G, no requests fire (toggle on).

Mobile

  • [ ] On a touch device (or device emulation), tapping a link prefetches; there are no hover-only side effects.
  • [ ] Disabling "Prefetch on mobile / touch" stops tap prefetch.

Robustness

  • [ ] Navigating after a prefetch feels instant (second visit to the page is cache-fast).
  • [ ] No console errors on any of the above.
  • [ ] The site's own scripts and forms behave identically with the plugin active.
  • [ ] Works with a page-builder theme (Elementor/Beaver/Kadence style output) without modification.

8. Known limitations

  • Cacheability dependent. Prefetch only helps if the destination page is cacheable. Pages served with Cache-Control: no-store (some members-only or dynamic endpoints) will still be fetched but not usefully cached. Add such paths to exclusions if you see needless requests.
  • Server still sees one extra GET per hovered link. That is inherent to any prefetch approach; the delay, dedupe, cap, exclusions, and network toggles keep it bounded. If you use server-side "page view" counting, exclude those paths.
  • Keyboard users. Intent is detected from pointer events only; keyboard-activated links are not prefetched (deliberate — see PRD Non-Goals).
  • One fetch per URL per page view. A failed fetch is not retried on the same page (intentional, keeps network minimal).
  • Modern browsers assumed. fetch, Pointer Events, and navigator.connection are feature-detected; ancient browsers simply get no behaviour (never an error).
  • No prerendering. The plugin warms caches; it does not render hidden pages (memory cost, not needed for slice 1).
  • Mobile prefetch adds data use unless Save-Data is respected (default) or the visitor's device opts out.
  • Redirecting links are not warmed. Requests never follow redirects (security: the final destination has not passed eligibility checks), so a link whose server 301/302s to the real page will not be prefetched. This is deliberate.
  • State-changing GET links are never warmed. That is also deliberate: hovering must never fire a GET that changes state (e.g. ?add-to-cart=…, ?action=…&_wpnonce=…).

9. Files

intent-prefetch/
├── intent-prefetch.php                  ← plugin header, boot
├── uninstall.php                        ← removes the option
├── readme.txt                           ← WP readme
├── README.md                            ← this document
├── PLANNING/                            ← planning set (PRD, ARCHITECTURE, TECH-SPEC, DATA-FLOW, DATABASE, TASKS, DECISIONS-TO-MAKE, AI-NOTES, RESEARCH-NOTES) — not shipped in the install zip
├── tests/                               ← smoke harnesses (node + php), also excluded from the zip
├── admin/
│   ├── class-intent-prefetch-admin.php
│   └── views/settings-page.php
├── assets/js/intent-prefetch.js
└── includes/class-intent-prefetch.php

License: GPL-2.0-or-later.