DEV Community

Cover image for I built a prefetcher that knows when to stop
Amit Dudhat
Amit Dudhat

Posted on

I built a prefetcher that knows when to stop

A few years ago, Alexandre Dieulot released instant.page. It was a fantastic piece of work. You dropped a 1KB script into your HTML, and page transitions felt instantaneous because the browser started fetching the HTML the second someone hovered over a link.

It felt like free performance.

After running naive prefetchers across a few production projects, though, you start noticing the flaws. Two problems in particular kept bugging me:

First, waiting for pointerover is usually too late. On a fast, deliberate click, hover gives you very little runway before the mouse button fires, ignoring the entire flight path that came before it.

Second, and this is the bigger issue, most prefetchers are recklessly optimistic. They assume the client has great bandwidth, an unmetered connection, plenty of battery, and that your origin server is healthy. If your database connection pool maxes out or an API route starts throwing 503 Service Unavailable or 429 Too Many Requests, a naive prefetcher does not care. Hundreds of concurrent visitors moving their mice around will keep pelting your struggling backend with speculative requests, turning a small traffic hiccup into a cascading outage.

I wanted something smarter: a library that could catch user intent before the cursor arrives, with enough self-awareness to back off the moment resources get tight.

That is why I built Flash Page. It is a zero-dependency, vanilla JS library (around 8KB gzipped) that rethinks how speculative navigation should work.


Predicting the click before the hover

If you watch how people use mice and trackpads, they do not teleport. When someone decides to click a link, their hand accelerates across the screen along a relatively straight vector toward that target. Waiting for the cursor to touch the anchor means ignoring that entire trajectory.

In Flash Page, instead of only listening for hovers, I set up a pointer tracker throttled to around 40Hz (sampling every ~25ms). When the cursor moves, it calculates the velocity vector (vx, vy) and checks if the movement represents deliberate momentum rather than idle drift or a wild gesture across the screen.

If the cursor is moving deliberately, Flash Page casts a 3-point proximity cone forward along the heading with a 120ms lookahead horizon. Since vx and vy are in pixels per millisecond, multiplying by 120 projects the cursor position roughly 120 milliseconds into the future:

// Sampling the pointer vector at ~40Hz
const vx = (event.clientX - lastX) / dt;
const vy = (event.clientY - lastY) / dt;
const speedSq = vx * vx + vy * vy;

// Skip slow drift (< 0.3 px/ms) or chaotic flicks (> 3.9 px/ms)
// speedSq is in px²/ms², so 0.1 to 15 corresponds to roughly 0.3 to 3.9 px/ms
if (speedSq >= 0.1 && speedSq <= 15) {
  const projDist = 120; // 120ms temporal lookahead horizon (vx is px/ms)
  const len = Math.sqrt(speedSq);

  // Normal vector to fan out a cone
  const nx = -vy / len;
  const ny =  vx / len;
  const spread = 25;

  const testPoints = [
    { x: event.clientX + vx * projDist, y: event.clientY + vy * projDist },
    { x: event.clientX + vx * projDist + nx * spread, y: event.clientY + vy * projDist + ny * spread },
    { x: event.clientX + vx * projDist - nx * spread, y: event.clientY + vy * projDist - ny * spread }
  ];

  for (const pt of testPoints) {
    const el = document.elementFromPoint(pt.x, pt.y);
    const anchor = el?.closest('a[href]');
    if (anchor && isPreloadable(anchor)) {
      preload(anchor.href, 'low');
      break;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

By testing three points (one directly along the 120ms projected path and two slightly flared out to the sides), we catch links that the user is heading toward before the cursor arrives. With a 120ms lookahead horizon, the theoretical lead time is up to that full 120ms window before the cursor even touches the link.

If someone is just casually drifting their mouse over text or resting their hand, the speed threshold ignores it and falls back to a clean 65ms hover debounce.


What about mobile? (A tiny client-side Markov model)

Mouse velocity works well on desktop. On a phone or tablet, however, there is no cursor to track. The first event you get is pointerdown, which gives you virtually zero runway before navigation.

To help with this, Flash Page keeps track of navigation sequences locally using a tiny Markov chain.

Most websites have predictable user flows. If someone is reading /docs/installation, there is a high probability their next stop is /docs/quickstart.

Whenever a user navigates between pages on your site, Flash Page updates a transition frequency table in the browser's localStorage:

// On route changes:
recordTransition(previousPath, currentPath);

// During idle time on the current page:
requestIdleCallback(() => {
  const nextPath = predictNextLink(window.location.pathname);
  if (nextPath) {
    preloadMatchingLink(nextPath);
  }
});
Enter fullscreen mode Exit fullscreen mode

Two design choices here were non-negotiable:

  1. Privacy and zero tracking: This transition table lives strictly in the user's localStorage as a small JSON map, capped at 50 source paths (a few KB at most). It never sends a single byte to an external server. A performance utility has no business acting as an analytics tracker.
  2. Conservative heuristics: To avoid wasting mobile data, Flash Page will not speculate on a hunch. It only triggers a prefetch if a specific path transition has occurred at least 3 times on that device and has at least a 60% probability.

One caveat for WebKit: Safari's storage policies can clear script-written localStorage after a period of user inactivity, which means transition counts may periodically reset for infrequent visitors.


Using native Speculation Rules instead of fighting the browser

Historically, prefetching meant either appending <link rel="prefetch" as="document" href="..."> into the DOM or firing off a background fetch().

Both approaches have quirks. fetch() leaves you with the problem of managing response caches and does not prepare subresources. <link rel="prefetch"> is treated inconsistently across browsers (WebKit has historically not supported document prefetching by default).

Chrome introduced early versions of list-based Speculation Rules (source: "list") in 2022-2023. What arrived in Chromium 121, though, was the addition of Document Rules.

Instead of requiring JavaScript to manually parse anchor tags and inject individual URLs into a list, you can inject declarative document rules. The browser's native C++ engine automatically monitors matching links in the DOM and handles the speculation out-of-process on a background network thread:

<script type="speculationrules">
{
  "prefetch": [
    {
      "source": "document",
      "where": {
        "and": [
          { "href_matches": "/*" },
          {
            "not": {
              "selector_matches": [
                "[data-no-flash]",
                "[download]",
                "[rel~='nofollow']",
                "[href*='/logout']"
              ]
            }
          }
        ]
      },
      "eagerness": "moderate"
    }
  ]
}
</script>
Enter fullscreen mode Exit fullscreen mode

When a user clicks a prefetched link, network latency for the HTML document itself is already eliminated (TTFB drops down to near-zero cache-hit speeds). If you configure specrules: 'prerender', Chromium renders the entire target page in a background process, making the visible transition practically instantaneous.

A subtle point about eagerness: in the rule above, Chromium's native moderate eagerness on desktop triggers when a user hovers over a link for ~200ms or presses down. What native rules cannot do on their own is project vector trajectory cones before the hover happens, or respect dynamic server backpressure, which is where Flash Page steps in.

Because browser support is not uniform across the web, Flash Page detects engine capabilities and chooses the cleanest available strategy:

  • Chromium 121+: Injects declarative Document Rules (with fallback to targeted speculation-list injection).
  • Firefox and older Chrome: Injects <link rel="prefetch" as="document">.
  • Safari / WebKit: Since Safari has historically not supported document prefetch tags by default, Flash Page falls back to an internal cache-warming fetch() request with { priority: 'low' } so that HTML and critical headers get seeded into the HTTP cache. As with any HTTP prefetch, warming the browser cache only helps if the response is cacheable; pages served with Cache-Control: no-store or private will naturally not benefit from background fetching.

Backpressure: Knowing when to stop

This was the main reason I built Flash Page in the first place.

Prefetched requests are non-essential by definition. If your server is healthy, prefetching makes your product feel great. But if your origin is groaning under heavy load, speculative requests are pure waste.

If a server responds to a fetch()-based speculative request with an HTTP 429 Too Many Requests or 503 Service Unavailable, Flash Page immediately inspects the response and pulls the emergency brake:

if (res.status === 429 || res.status === 503) {
  const retryHeader = res.headers.get('Retry-After');
  let waitSeconds = 30; // sensible fallback

  if (retryHeader) {
    const asSeconds = Number(retryHeader);
    const parsed = Number.isFinite(asSeconds)
      ? asSeconds
      : (Date.parse(retryHeader) - Date.now()) / 1000;

    if (Number.isFinite(parsed)) {
      waitSeconds = Math.min(300, Math.max(0, Math.round(parsed))); // cap at 5 min
    }
  }

  setBackpressure(waitSeconds);
}
Enter fullscreen mode Exit fullscreen mode

When backpressure is tripped, all JavaScript-directed operations stand down: no vector lookaheads, no hover timers, and no idle Markov predictions. If your API says "back off for 60 seconds", Flash Page suspends all dynamic prefetch queues until the window clears. Note that if a declarative <script type="speculationrules"> tag has already been injected into the DOM on Chromium, the browser engine continues processing those document rules until navigation; the client-side backpressure pause governs all dynamic JavaScript queues and subsequent speculative requests.

A technical detail on how this applies across different engines:

  • On the fetch() path (Safari cache warming and manual preloads), Flash Page inspects responses directly and triggers backpressure automatically.
  • On Chromium's native Speculation Rules path, the browser makes requests out of process, meaning client JavaScript cannot read a 503 response. Here, the defense is handled on the server or CDN layer: reverse proxies can inspect incoming headers and return a 503 or drop speculative requests when origin load spikes. The browser silently discards that failed speculation, and real user clicks still work normally.
  • On the client side, if your application monitoring, telemetry, or service worker detects degraded backend health, you can also trigger backpressure programmatically across all client-directed operations by calling setBackpressure(seconds).

Additionally, because browsers forbid client JavaScript from setting Sec-* headers, Flash Page tags its same-origin fallback fetches with Purpose: prefetch. Meanwhile, Chromium's native engine automatically attaches Sec-Purpose: prefetch to its out-of-process requests. Both headers give CDNs and reverse proxies an immediate signal to shed speculative traffic at the edge before it ever reaches your origin.

Before doing any speculative work, it also checks the device environment:

  • Connection quality: If navigator.connection.saveData is turned on, or if the user is on a slow 2G connection, prefetching is turned off entirely.
  • Battery state: If the device is discharging and battery drops below 20%, it pauses to conserve power.
  • Low memory: If navigator.deviceMemory reports 1GB or less, it steps aside.
  • Concurrency cap: It limits simultaneous inflight preloads to 3 requests, using low-priority fetches (priority: 'low') to minimize bandwidth contention with current-page images or API calls.

Keep in mind that navigator.connection, getBattery(), and deviceMemory are primarily Chromium APIs. Where available, Flash Page uses them; on Safari and Firefox, where these APIs are not exposed, those hardware checks are safely skipped and prefetching continues normally.


Not breaking applications

Prefetching can cause real headaches if you do not account for state-changing links.

Flash Page comes with sensible guards out of the box:

  • It automatically skips destructive URLs (/logout, /signout, /delete, /destroy, /remove).
  • It skips links with framework action attributes like Rails or Turbo's [data-method], [data-turbo-method], or HTMX's [hx-post] and [hx-delete].
  • It ignores links with target="_blank", download, or rel="nofollow".
  • It uses a MutationObserver under the hood so newly mounted links in SPAs (React, Vue, Svelte, Turbo streams) are automatically monitored without having to call an update method.
  • Heads-up on prerendering: If you choose to enable full prerendering (specrules: 'prerender'), be aware that the browser executes page JavaScript in a hidden prerender context (not a visible tab). If your application records analytics pageviews or auto-plays media immediately on script execution, scripts should check document.prerendering or listen for the prerenderingchange event so those actions only fire once the user actually views the page.

The DevTools HUD (Ctrl + Shift + F)

When you build something that makes decisions based on cursor vectors and probability tables, you need a way to inspect what it is doing in real time without digging through console logs.

Pressing Ctrl + Shift + F (or Cmd + Shift + F on Mac) toggles a tiny diagnostic HUD right on the page. If that key combination collides with a browser extension or dev tool in your environment, you can also trigger the overlay programmatically by calling toggleHUD().

Inside the HUD:

  • Shows which engine is active (document-rules, speculation-list, link, or fetch).
  • Displays live queue depth and active requests.
  • Shows whether server backpressure is currently active (and how many seconds remain).
  • Highlights speculated links on the page with a subtle cyan outline, so you can watch your cursor trajectory trigger preloads as you move across the screen.

How to use it

Flash Page is distributed as pure vanilla JS with zero dependencies in two builds:

  • Full bundle (~8KB gzipped): Vector trajectory, Markov idle predictions, DevTools HUD, and critical CSS/font subresource pre-warming.
  • Lite bundle (~5.7KB gzipped): Stripped down to essential prefetching for clean blogs and documentation sites.

If you already use instant.page, Flash Page is designed to be backward-compatible, honoring existing data-instant and data-no-instant markup alongside newer data-flash directives.

You can drop it in via CDN:

<script src="https://cdn.jsdelivr.net/npm/flash-page@1.0.1/flashpage.min.js" type="module"></script>
Enter fullscreen mode Exit fullscreen mode

Or host locally

<script src="/flashpage.min.js" type="module"></script>
Enter fullscreen mode Exit fullscreen mode

Or install it through NPM:

npm install flash-page
Enter fullscreen mode Exit fullscreen mode
import { init } from 'flash-page';

init({
  intensity: 'predictive', // Enables cursor trajectory vectoring
  specrules: 'prefetch',   // 'prefetch' | 'prerender' | 'no'
  markov: true,            // Enables idle-time Markov navigation prediction
  subresources: true,      // Pre-warms render-blocking CSS & web fonts
  backpressure: true       // Honors 429/503 Retry-After headers
});
Enter fullscreen mode Exit fullscreen mode

Final thoughts

This is an active project, and I want to be honest about its limits. Mouse trajectory on desktop feels great in practice, but predicting intent on touch devices will always be constrained by the lack of pre-touch input.

The goal with Flash Page is not to download the entire internet before someone clicks. It is to make a thoughtful, well-timed guess when someone is clearly heading for a link, and to keep that guess from costing the user bandwidth or adding load to a server that is already struggling.

If you maintain high-traffic backends or work on frontend performance:

  • How does your CDN cache layer handle speculative document traffic?
  • What concerns would you have before turning something like this on?

The code is fully open source:

👉 GitHub: FlashPageJS/FlashPage

Top comments (2)

Collapse
 
respect17 profile image
Kudzai Murimi •

The backpressure handling is what separates this from every other prefetcher, most of them would happily hammer a struggling origin with speculative requests during an outage. Respecting Retry-After and stepping down on battery/saveData/low memory too is the kind of detail that only shows up after running one in production.

Collapse
 
wpamitkumar profile image
Amit Dudhat •

Exactly. Speculative traffic should always be the very first thing to yield when resources get tight, whether that is server capacity, battery, or metered data. Thanks for reading and taking the time to share your thoughts!