Preloading and Prefetching Remote Entry Files #
A federated remote costs at least three sequential round trips before it renders anything — this page removes two of them with modulepreload, preconnect and an intent-driven prefetch.
The waterfall is structural rather than accidental. The browser cannot know a remote exists until the host’s JavaScript executes and asks for it, so discovery is late by construction. Everything here is about moving discovery earlier without downloading things nobody needs. It complements performance budgets for federated apps, which governs how much you are allowed to fetch at all.
Prerequisites #
- Webpack 5 Module Federation or Vite with a federation plugin, serving remotes from a known origin.
- Control over the host’s HTML
<head>— a server-rendered shell or a template you can inject into at build time. - Remotes served with immutable, fingerprinted filenames, as described in CDN cache invalidation for federated remotes.
- A route-to-remote map, so the host knows which remotes a given URL will need.
Step 1 — Understand the waterfall you are removing #
On a cold load of a route owned by a remote, the browser does this in order: fetch the host HTML, fetch and execute the host bundle, discover the remote URL, open a connection to the remote origin, fetch remoteEntry.js, read the chunk list, fetch the exposed chunk, then render. Only the last two steps are about the remote’s actual code.
The connection setup alone is expensive when the remote lives on a different origin. DNS, TCP and TLS to a new host is commonly 150 to 300 ms on mobile, and it happens after your bundle has already executed.
Step 2 — Preconnect to every remote origin #
The cheapest win requires no knowledge of which remote you need, only where remotes live. Put it in the static <head> so it is discovered by the preload scanner before any script runs.
<!-- index.html — discovered before the host bundle even parses -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="preconnect" href="https://checkout-cdn.example.com" crossorigin>
The crossorigin attribute is required, not optional. Module scripts are fetched in CORS mode, and a preconnect without it warms a connection the module fetch will not reuse — leaving you with two connections and no saving.
Limit this to origins you will genuinely use on most page views. Each preconnect holds a socket open, and half a dozen speculative connections compete with the requests that matter.
Step 3 — Preload the entry for remotes this route definitely needs #
For remotes the route is guaranteed to load, modulepreload starts the fetch during HTML parsing instead of after bundle execution.
<!-- Emitted per route: only the remotes this URL is certain to need -->
<link rel="modulepreload" href="https://cdn.example.com/cart/remoteEntry.a91f3c.js" crossorigin>
Generating these is the interesting part, because the fingerprinted filename changes on every remote release. Read it from the same manifest the host resolves at runtime.
// server/render-head.js — inject preloads from the live remote manifest
import { routeRemotes } from './route-map.js';
export async function preloadTags(pathname) {
const manifest = await fetchManifest(); // the same /remotes.json the host reads
return (routeRemotes[pathname] ?? [])
.map((name) => manifest[name]?.entry)
.filter(Boolean)
.map((url) => `<link rel="modulepreload" href="${url}" crossorigin>`)
.join('\n');
}
Using the manifest rather than a build-time constant is what keeps this correct. A hard-coded preload URL becomes a wasted 404 the first time a remote deploys, and worse, it stays wasted silently.
Preload only what the route is certain to need. A preloaded asset that goes unused is strictly worse than no preload: you paid the bandwidth and the connection contention for nothing, and Chrome will warn about it in the console.
Step 4 — Prefetch the remotes a user is about to need #
Prefetch is for probable, not certain. It fetches at low priority into the HTTP cache, so if the navigation happens the entry is already local, and if it does not you have spent idle bandwidth.
// src/prefetch-remote.js — fetch at idle priority, never block anything
const prefetched = new Set();
export function prefetchRemote(url) {
if (prefetched.has(url) || !url) return;
prefetched.add(url);
// Respect a user who has asked for reduced data usage.
if (navigator.connection?.saveData) return;
if (/(2g|slow-2g)/.test(navigator.connection?.effectiveType ?? '')) return;
const link = document.createElement('link');
link.rel = 'prefetch';
link.as = 'script';
link.crossOrigin = 'anonymous';
link.href = url;
document.head.appendChild(link);
}
The Save-Data and connection-type guards are not politeness — on a metered 2G connection a speculative prefetch can measurably delay the content the user actually asked for.
Step 5 — Trigger the prefetch from real intent #
Intent signals in increasing order of confidence: the link is in the viewport, the pointer is moving toward it, the pointer is over it, the pointer is down. Viewport visibility is the best trade-off for navigation-level prefetching.
// src/prefetch-on-visible.js
import { prefetchRemote } from './prefetch-remote';
import { routeRemotes } from './route-map';
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const href = entry.target.getAttribute('href');
(routeRemotes[href] ?? []).forEach((name) => prefetchRemote(manifest[name]?.entry));
observer.unobserve(entry.target); // once is enough
}
}, { rootMargin: '200px' });
document.querySelectorAll('a[href^="/"]').forEach((a) => observer.observe(a));
Hover is a stronger signal but arrives roughly 200 ms before the click, which on mobile does not exist at all. Viewport visibility fires seconds earlier and covers touch devices, which is where the latency hurts most.
Step 6 — Prime the shared scope while you are at it #
Preloading the entry removes one round trip, but the exposed chunk is still discovered only after the container initialises. If a remote is certain to render, you can warm the whole path during idle time.
// src/warm-remote.js — resolve the module early, render later
import { loadRemote } from './load-remote';
export function warmRemote(name, module, url) {
const run = () => loadRemote(name, module, url).catch(() => {
// A failed warm-up must never surface: the real load will retry and report.
});
'requestIdleCallback' in window
? requestIdleCallback(run, { timeout: 2000 })
: setTimeout(run, 300);
}
Because loadRemote() caches its promise, the later real import resolves from cache instead of repeating any of the work. Swallowing the error here is deliberate — a speculative warm-up that fails should be invisible, and the genuine load will surface the failure through its own error boundary.
Step 7 — Decide what each route is allowed to speculate on #
Preloading is a budget decision as much as a performance one, and it is the place where well-meaning teams undo their own work. Every hint you add competes for the same connection pool and the same bandwidth as the content the user asked for, so the useful question is not “can we preload this?” but “what are we willing to delay in order to preload this?”.
A workable rule is one preload per above-the-fold remote, and nothing else at high priority. Everything beyond that — sibling routes, remotes behind a tab, the remote a user will probably need next — goes to prefetch, where it consumes only idle capacity. If a route needs more than two or three preloads to feel fast, the actual problem is the number of remotes on that route, and no amount of hinting will fix it.
It is also worth auditing the hints as deliberately as you audit the bundles. Route maps drift: a remote gets renamed, a page stops rendering a widget, a redirect is added, and the preload keeps firing for months because nothing fails when it is wrong. A weekly check that every emitted hint corresponds to a request the page actually made — easily derived from the same RUM beacons described in measuring Time to Interactive with remote modules — keeps the speculation honest.
Finally, remember that hints do not survive a rollback. If you revert a remote to a previous version, the manifest changes and every generated hint changes with it. Deriving the hints from the live manifest, rather than baking them into the host build, is what makes that automatic rather than a second deploy nobody remembers to do.
Verification #
- The waterfall is shorter: record a Performance profile with network throttling. The
remoteEntry.jsrequest should start during HTML parsing, not after the host bundle finishes executing. - Preconnect is used, not wasted: in the Network panel, the remote entry request should show no Initial connection or SSL time. If it does, the
crossoriginattribute is missing. - No unused preload warnings: the console should be free of “was preloaded using link preload but not used” messages. Each one is a route map that has drifted from reality.
- Prefetch is low priority: the Priority column should read Lowest for prefetched entries. Anything higher means
asis wrong and the prefetch is competing with real requests. - Save-Data is honoured: enable data saver, reload, and confirm no
rel=prefetchtags are appended.
Troubleshooting #
Symptom: the remote is fetched twice.
Diagnosis: the preload URL and the URL the host resolves at runtime differ — usually a fingerprint mismatch because the preload came from a build-time constant while the runtime read the manifest. Fix: generate both from the same manifest, as in step 3.
Symptom: console warns that a preloaded resource was not used.
Diagnosis: the route map lists a remote the route no longer renders, or the preload fires on a route that redirects. Fix: derive the route map from the same configuration the router uses rather than maintaining it by hand.
Symptom: preloading made the page slower.
Diagnosis: too many high-priority preloads competing with the host bundle and the LCP image for bandwidth. Fix: preload only above-the-fold remotes and demote the rest to prefetch — see lazy loading remotes below the fold.
Symptom: prefetched entries are re-downloaded on navigation.
Diagnosis: the remote entry is served without a long cache lifetime, so the prefetched copy is not reusable. Fix: serve fingerprinted entries as immutable, which is the same requirement that makes rollback safe.