Measuring Time to Interactive with Remote Modules #
A page-level Time to Interactive number tells you the federated route is slow but never which remote made it slow — this page shows how to instrument the load so every millisecond is attributed to a named remote.
The technique is to bracket each phase of a remote’s lifecycle with performance.mark, derive measures from those marks, and ship the result alongside the standard Web Vitals so one dashboard answers both “how slow” and “whose”. It is the measurement half of performance budgets for federated apps.
Prerequisites #
- A host and at least two remotes composed with Webpack 5 Module Federation or Vite with a federation plugin.
- web-vitals
^4.2.0for the standard metrics, or your existing RUM agent. - Remotes loaded through a single helper (a
loadRemote()wrapper) rather than scattered bareimport()calls — see dynamically loading remote modules at runtime. - A preview environment where the host resolves real remotes. Measuring against mocks measures the mocks.
One assumption worth stating: interactive as Lighthouse defines it is a lab metric with no direct field equivalent. In production you approximate it with Total Blocking Time and Interaction to Next Paint. This page instruments the lab metric for attribution and the field metrics for truth.
Step 1 — Mark every phase of a remote’s lifecycle #
Four boundaries matter: when the host decided it needed the remote, when the container script finished loading, when the exposed module resolved, and when the remote’s first paint completed. Anything coarser hides the phase that is actually slow.
// src/remote-timing.js — one helper, used by every remote load
export function markRemote(name, phase) {
performance.mark(`remote:${name}:${phase}`);
}
export function measureRemote(name) {
const span = (label, from, to) => {
try {
performance.measure(`remote:${name}:${label}`, from, to);
} catch {
// A missing mark means that phase never ran — a finding, not an error.
}
};
span('entry', `remote:${name}:request`, `remote:${name}:entry-loaded`);
span('resolve', `remote:${name}:entry-loaded`, `remote:${name}:module-resolved`);
span('render', `remote:${name}:module-resolved`, `remote:${name}:painted`);
span('total', `remote:${name}:request`, `remote:${name}:painted`);
}
Wrapping performance.measure in a try/catch matters more than it looks. When a remote fails to load, the later marks never exist, and an unguarded call throws inside your instrumentation — turning a slow remote into a broken page.
Step 2 — Wire the marks into the loader #
Put the marks in the shared loader rather than in each remote, so a new remote is instrumented the moment it is added and nobody has to remember.
// src/load-remote.js
import { markRemote, measureRemote } from './remote-timing';
const cache = new Map();
export function loadRemote(name, module, url) {
const key = `${name}/${module}`;
if (cache.has(key)) return cache.get(key);
markRemote(name, 'request');
const promise = injectScript(url)
.then(() => {
markRemote(name, 'entry-loaded');
return initContainer(name);
})
.then((container) => container.get(module))
.then((factory) => {
markRemote(name, 'module-resolved');
return factory();
});
cache.set(key, promise);
return promise;
}
The painted mark cannot live here, because the loader has no idea when React or Vue actually committed the remote to the DOM. That mark belongs in the remote’s own root component.
// inside the remote's exposed root component
import { useEffect } from 'react';
import { markRemote, measureRemote } from 'host/remote-timing';
export default function OrdersRoot(props) {
useEffect(() => {
// Two frames: one for the commit, one for the browser to actually paint it.
requestAnimationFrame(() => requestAnimationFrame(() => {
markRemote('orders', 'painted');
measureRemote('orders');
}));
}, []);
return <OrdersTable {...props} />;
}
The double requestAnimationFrame is deliberate. A single frame fires after the commit but before the paint, which systematically under-reports render time by roughly one frame on every remote.
Step 3 — Correlate the phases with page-level metrics #
Now join the per-remote measures to the standard vitals so a regression in one can be explained by the other.
// src/report-vitals.js
import { onLCP, onTTFB, onINP, onCLS } from 'web-vitals';
function collectRemoteSpans() {
return performance.getEntriesByType('measure')
.filter((m) => m.name.startsWith('remote:'))
.map((m) => {
const [, name, phase] = m.name.split(':');
return { remote: name, phase, ms: Math.round(m.duration) };
});
}
function send(metric) {
const body = JSON.stringify({
metric: metric.name,
value: Math.round(metric.value),
route: location.pathname,
remotes: collectRemoteSpans(),
});
// sendBeacon survives the page being backgrounded or unloaded mid-report.
navigator.sendBeacon('/rum', body);
}
[onLCP, onTTFB, onINP, onCLS].forEach((on) => on(send));
Sending the remote spans with every vital, rather than as a separate event, is what makes attribution possible without a join key. A slow LCP arrives with the exact remote timings from that same page view attached.
Step 4 — Add a long-task observer to catch main-thread cost #
Bytes and durations do not explain jank. A remote can load quickly and still block the main thread for 300 ms while it hydrates. PerformanceObserver with the longtask type catches exactly that.
// src/long-tasks.js — attribute blocking time to whichever remote was mounting
let mounting = null;
export const setMounting = (name) => { mounting = name; };
new PerformanceObserver((list) => {
for (const task of list.getEntries()) {
if (task.duration < 50) continue;
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'long-task',
value: Math.round(task.duration),
// Best-effort attribution: whichever remote was mid-mount when it fired.
attributedTo: mounting ?? 'host',
route: location.pathname,
}));
}
}).observe({ type: 'longtask', buffered: true });
Attribution here is best-effort by design. The browser will not tell you which module ran during a long task, so pairing it with the currently-mounting remote is an approximation — but on a route where remotes mount sequentially it is accurate enough to point at the right team.
Step 5 — Run the lab measurement in CI #
The field data tells you what users experience; the lab measurement is what fails a build. Run Lighthouse against the composed preview and assert on both the page metric and the per-remote spans.
// scripts/assert-tti.mjs
import { launch } from 'chrome-launcher';
import lighthouse from 'lighthouse';
const BUDGETS = { tti: 3800, tbt: 300, perRemoteTotal: 900 };
const chrome = await launch({ chromeFlags: ['--headless=new'] });
const { lhr, artifacts } = await lighthouse(process.env.PREVIEW_URL + '/checkout', {
port: chrome.port, formFactor: 'mobile', throttlingMethod: 'simulate',
}, undefined, { output: 'json' });
await chrome.kill();
const failures = [];
if (lhr.audits.interactive.numericValue > BUDGETS.tti) failures.push('TTI over budget');
if (lhr.audits['total-blocking-time'].numericValue > BUDGETS.tbt) failures.push('TBT over budget');
for (const m of artifacts.UserTimings ?? []) {
if (!m.name.endsWith(':total')) continue;
if (m.duration > BUDGETS.perRemoteTotal) {
failures.push(`${m.name}: ${Math.round(m.duration)}ms over ${BUDGETS.perRemoteTotal}ms`);
}
}
failures.forEach((f) => console.error(f));
process.exitCode = failures.length ? 1 : 0;
Because artifacts.UserTimings picks up the performance.measure entries created in step 1, the failure message names the remote directly. That is the difference between a build failure someone acts on and one someone re-runs.
Step 6 — Keep the instrumentation from becoming the problem #
Instrumentation that costs more than it reveals gets deleted, usually right after the first incident it failed to explain. Three habits keep it cheap enough to leave switched on permanently.
Sample the field reporting. Every page view creating a beacon is unnecessary once you have thousands of them a day, and on a busy route the reporting itself becomes measurable. A 10 per cent sample gives stable percentiles for anything above the median, and you can always raise it temporarily while investigating.
// Sample in the field, always report in the lab.
const SAMPLE = navigator.userAgent.includes('HeadlessChrome') ? 1 : 0.1;
const shouldReport = Math.random() < SAMPLE;
Clear the marks after reporting. performance buffers are finite, and on a long-lived single-page application that mounts and unmounts remotes across navigations, the buffer fills with stale entries until new marks are silently dropped. Calling performance.clearMarks() and performance.clearMeasures() scoped to a remote’s prefix after each report keeps the buffer bounded without losing anything you have already sent.
Finally, treat a missing mark as data. When the painted mark never appears for a remote that was requested, that is not a gap in your telemetry — it is a remote that failed to render, and it deserves to be reported as such. Emitting an explicit never-painted event when a request has no matching paint after a timeout turns a silent blank region into an alert, and it pairs naturally with the error boundary telemetry already wrapping each remote.
Verification #
Confirm the instrumentation is real before you trust a number from it:
- Marks exist: in DevTools, run
performance.getEntriesByType('mark').filter(m => m.name.startsWith('remote:')). You should see four marks per remote on the route. - Phases sum correctly: the
totalmeasure should equalentry + resolve + renderwithin a millisecond or two. A large gap means a phase is unmarked. - Timeline agrees: record a Performance profile. The user-timing track should show each remote’s spans lining up with the actual script evaluation, not floating somewhere unrelated.
- Failure is visible: throttle one remote’s origin to Slow 3G in the Network conditions panel. Its
entryspan should grow while the others stay flat — if every span grows, you are measuring the page rather than the remote. - Beacons arrive: check the Network panel for the
/rumrequest and confirm theremotesarray is populated rather than empty.
Troubleshooting #
Symptom: every remote reports a near-identical total, roughly equal to page load.
Diagnosis: the request mark is being set at module-evaluation time rather than when the load actually starts, so all remotes inherit the same start point. Fix: set the mark inside loadRemote() immediately before the script injection, not at the top of the module that calls it.
Symptom: the render span is negative or zero.
Diagnosis: the painted mark fired before module-resolved, which happens when a remote is preloaded and its component mounts from cache in the same tick. Fix: guard the mark so it only fires once per remote per page view, and treat a sub-millisecond render as a cache hit rather than a measurement.
Symptom: spans appear in the lab run but never in field data.
Diagnosis: performance.getEntriesByType('measure') is being called before the remotes have finished mounting, so it returns an empty array. Fix: collect the spans inside the vitals callback rather than at page load, as in step 3 — vitals fire late enough that the remote timings exist.
Symptom: long tasks are all attributed to the host.
Diagnosis: setMounting() is never called, so the observer falls back to its default. Fix: call it from the loader immediately before the factory runs and clear it after the painted mark, so the attribution window covers the actual mount.