JORVEL

Prefetch on hover

NavLink can warm the target remote bundle on hover, focus, and touch-start so the navigation feels instant. Build a central NavLinkPrefetchProvider in the host and turn it on per link, or call the imperative prefetchRoute() from any event handler.

When does prefetch pay off?

Hover-to-click latency averages 200–400ms on desktop. Prefetching during that window often loads the entire remote before the click lands, so the next view feels instant. On mobile, the equivalent signal is touchstart — roughly 80ms before the actual click.

Configure

Wrap your app once with the provider so every NavLink prefetch resolves through the same routes + remotes config. The provider is essentially a React context — no extra runtime cost when prefetch is off.

tsx
import { NavLink, NavLinkPrefetchProvider } from '@jorvel/runtime';

const REMOTES = {
  dashboard: { name: 'dashboard', entryUrl: '/jorvel/remotes/dashboard/remoteEntry.js' },
};

const HOST_ROUTES = [
  { path: '/dashboard/*', remote: 'dashboard', module: './App' },
  { path: '/',            remote: 'dashboard', module: './App' },
];

<NavLinkPrefetchProvider config={{ routes: HOST_ROUTES, remotes: REMOTES }}>
  <NavLink to="/dashboard/settings" label="Settings" prefetch />
</NavLinkPrefetchProvider>

Need a different routes/remotes map for a link (e.g. an admin destination served from a different federation graph)? Pass the prefetch config inline.

tsx
<NavLink to="/profile" label="Profile" prefetch={{
  routes: HOST_ROUTES,
  remotes: { profile: { name: 'profile', entryUrl: '...' } },
}} />

Imperative API

Fire a prefetch from any event handler — keyboard shortcut, custom hover region, viewport-observer. The call is deduped by URL so spamming it from anIntersectionObserver is safe.

ts
import { prefetchRoute } from '@jorvel/runtime';

await prefetchRoute('/dashboard/reports', { routes: HOST_ROUTES, remotes: REMOTES });

How it works

  1. Resolves the URL against routes to find the target remote name + entry URL.
  2. Inserts a <link rel="prefetch" as="script"> for the remoteEntry.
  3. Calls loadRemoteEntry in the background — dedupes across the prefetch and the eventual real load, so the navigation pays only the React-render cost.
  4. Emits jorvel:remote-load telemetry with { source: 'prefetch' } so observability dashboards can separate proactive loads from on-demand.
  5. Respects the user's connection: navigator.connection.saveData === true skips prefetch.
  6. Respects prefers-reduced-data: returns immediately without inserting the link.

Event triggers on NavLink

prefetch on a NavLink attaches three listeners. Each fires once per link, then unsubscribes — the prefetch cache handles deduplication.

EventWhy
mouseenterDesktop intent — typical 200-400ms before click
focusKeyboard navigation; also fires when a tap focuses the link on mobile
touchstartMobile intent — ~80ms before the click event

Cancelling a prefetch

Prefetches are fire-and-forget — once started, they run to completion. If you need to invalidate (e.g. after auth changes that require different headers), wipe the dedup cache:

ts
import { resetPrefetchCache } from '@jorvel/runtime';

bus.on('auth:logout', resetPrefetchCache);   // force re-fetch with new auth headers

Bandwidth budget

Don't prefetch everything

Prefetching all remotes on every hover wastes bandwidth on metered connections. Limit prefetching to high-confidence next-clicks (top-nav links, hero CTAs) or use concurrent preload with idle: true after first paint.

Rough budget per remote (gzipped, includes remoteEntry.js + first chunk):

Remote size3G data costTime at 1Mbps
10 KBnegligible~80ms
40 KBtolerable on Wi-Fi~320ms
100 KBnoticeable on metered~800ms

Recipes

Prefetch the most-clicked nav link only

tsx
<nav>
  <NavLink to="/" label="Home" />
  <NavLink to="/dashboard" label="Dashboard" prefetch />   {/* 70% of clicks */}
  <NavLink to="/billing" label="Billing" />
  <NavLink to="/settings" label="Settings" />
</nav>

Prefetch after auth completes

ts
import { prefetchRoute } from '@jorvel/runtime';
import { bus } from './bus';

bus.on('auth:ready', () => {
  prefetchRoute('/dashboard', { routes: HOST_ROUTES, remotes: REMOTES });
});

Prefetch on scroll-into-view of a CTA

tsx
function HeroCta() {
  const ref = useRef<HTMLAnchorElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        prefetchRoute('/signup', { routes: HOST_ROUTES, remotes: REMOTES });
        io.disconnect();
      }
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return <a ref={ref} href="/signup">Get started</a>;
}

Prefetch vs. preload vs. eager

prefetch (this page)preload (concurrent)Eager (sync)
TriggerUser intentAfter first paintInitial load
CostPay-on-hover onlyOne-time after FPBlocks LCP
Use whenOne next-clickLikely next-clicks (2-3)Always-needed shell