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;
}
}
}
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);
}
});
Two design choices here were non-negotiable:
-
Privacy and zero tracking: This transition table lives strictly in the user's
localStorageas 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. - 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>
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 withCache-Control: no-storeorprivatewill 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);
}
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.saveDatais 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.deviceMemoryreports 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, orrel="nofollow". - It uses a
MutationObserverunder 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 checkdocument.prerenderingor listen for theprerenderingchangeevent 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, orfetch). - 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>
Or host locally
<script src="/flashpage.min.js" type="module"></script>
Or install it through NPM:
npm install flash-page
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
});
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)
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.
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!