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.

The four marks that bracket a remote's load Marking all four boundaries separates network time, federation overhead and render cost instead of reporting one opaque total. Where to put the performance marks request host needs the remote entry-loaded container script ready module-resolved factory returned painted first frame committed
A single 'remote loaded' timer cannot tell a slow CDN apart from a slow hydration — these four can.

Prerequisites #

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 #

Per-remote phase timings from one page view Splitting each remote into phases shows that the catalog remote's cost is rendering, not network — a completely different fix. Where the time actually goes, per remote orders — entry fetch 180 ms orders — resolve 42 ms orders — render 150 ms catalog — entry fetch 78 ms catalog — render 390 ms Durations from one throttled mobile page view; bars are relative to an 880 ms worst case.
Two remotes, two different problems: one is waiting on the network, the other is blocking the main thread.

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 #

Which metrics gate a build and which only inform Lab metrics are stable enough to fail a build; field metrics vary with real devices and belong on a trend line instead. Metric Measured by Fails the build? Time to Interactive Lighthouse, lab yes Total Blocking Time Lighthouse, lab yes Per-remote total span performance.measure yes, names the remote Interaction to Next Paint web-vitals, field no — trend only Long tasks PerformanceObserver, field no — trend only
Never fail a build on field data — it moves for reasons that have nothing to do with the pull request in front of you.

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:

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.