Introduction
Microfrontend architecture has become a mainstream strategy for scaling large frontend applications across independent teams. The core promise-decompose a monolithic UI into independently deployable, team-owned fragments-is compelling. But when those fragments are embedded as iframes communicating across origin boundaries, the engineering complexity of simply understanding what your system is doing at runtime becomes considerable.
Performance measurement in microfrontend systems is hard by default. Each embedded frame owns its own JavaScript engine, its own layout pipeline, and its own resource loading. The browser's built-in performance tooling-window.performance, DevTools' Performance panel, Lighthouse-operates within a single browsing context. The moment you cross an iframe boundary, you leave that context behind. Timings stop correlating. Traces diverge. The waterfall you're looking at is, at best, half the story.
This article is a systematic treatment of how to measure performance across nested iframe-based microfrontend architectures. It covers the browser primitives you need to understand, how to design a cross-frame instrumentation layer, how to correlate metrics across frame boundaries using the postMessage channel those frames already use to communicate, and how to avoid the common pitfalls that make such systems appear faster or slower than they actually are. Whether you're building a new platform or retrofitting observability into an existing one, the concepts and patterns here apply directly.
The Problem: Why Cross-Frame Performance Measurement Is Non-Trivial
To understand the challenge, it helps to think concretely about what "nested iframes that communicate with each other" actually implies at runtime.
When a shell application embeds child microfrontends as iframes, each child is a fully independent browsing context. It has its own window, its own document, and critically, its own Performance timeline. The performance.now() timestamp in an iframe is relative to that frame's timeOrigin-the moment that particular browsing context was created. The parent's performance.now() is relative to the parent's timeOrigin. These origins are not the same value. A message sent from parent to child at parent-time T1 does not arrive at child-time T1. The clock skew is real and can range from a few milliseconds to hundreds, depending on when the child frame navigated.
This clock skew means you cannot naively compare timestamps across frames. A latency calculation that subtracts a parent timestamp from a child timestamp will produce a number that includes the clock offset, not just the actual event latency. To correctly measure, say, the time between "parent dispatched a data event" and "child finished rendering in response to that event," you need a shared monotonic reference or a protocol that compensates for the skew-an exercise similar to network clock synchronization problems you encounter in distributed systems.
The origin boundary compounds the problem. When child iframes are served from a different origin than the shell-which is common in microfrontend deployments where each team controls their own domain or CDN path-the browser enforces strict isolation. You cannot access childFrame.contentWindow.performance from a cross-origin parent. The PerformanceObserver inside the child cannot report metrics to the parent without explicit cooperation from the child. Every piece of measurement telemetry must be explicitly forwarded through postMessage.
Finally, there is the attribution problem. When the main thread in the parent shell is janky, is the cause in the shell's own code, in a layout triggered by a child iframe resizing itself, or in postMessage handler overhead from a chatty child? These root causes have very different fixes, but they all appear as the same symptom: long tasks on the parent's main thread. Separating them requires coordinated instrumentation at every level.
Browser Primitives for Performance Measurement
Before designing a cross-frame instrumentation layer, you need a thorough understanding of what the browser exposes and where the boundaries are.
The Performance Timeline API
The Performance interface (window.performance) is the foundation. It exposes performance.now() for monotonic high-resolution timestamps, performance.mark() and performance.measure() for manual instrumentation, and performance.getEntriesByType() for accessing navigation, resource, and paint timing entries. Every browsing context has its own independent instance of this interface with its own timeOrigin.
performance.timeOrigin is the key to cross-frame correlation. It returns the Unix epoch millisecond timestamp at which the current browsing context's clock started. If you know two frames' timeOrigin values, you can convert timestamps from one frame's timeline into absolute wall-clock time and compare them. The challenge is acquiring the child's timeOrigin from the parent-since this requires the child to send it explicitly.
PerformanceObserver
PerformanceObserver allows asynchronous, non-blocking collection of performance entries. Rather than polling performance.getEntriesByType(), you register an observer for specific entry types (navigation, resource, paint, largest-contentful-paint, long-animation-frame, layout-shift, longtask, etc.) and receive batched notifications as entries become available.
Within an iframe, a PerformanceObserver works exactly as it does in any other browsing context-but it only sees that frame's own entries. A longtask entry in the child frame's observer means the child's main thread was blocked. It says nothing about the parent's thread, and vice versa. To get a complete picture, you need observers running independently in every frame, forwarding their observations through a common reporting channel.
Long Animation Frame API (LoAF)
Introduced in Chrome 116 and standardized in 2024, the Long Animation Frame API (long-animation-frame entry type) is a significant improvement over the older longtask entry type for diagnosing rendering bottlenecks. Where longtask only tells you that the main thread was busy for more than 50ms, a long-animation-frame entry includes script attribution-which scripts ran, how long each took, whether they were forced style/layout recalculations, and the rendering time breakdown.
For microfrontend architectures, LoAF entries in the parent frame attributing long frames to postMessage handlers are a strong signal of over-communication between frames. LoAF entries in a child frame attributing delays to layout recalculation in response to container resize events suggest the shell's iframe management is triggering expensive work in the child.
Resource Timing and Cross-Origin Restrictions
PerformanceResourceTiming entries expose detailed network timing for each resource loaded by a browsing context. However, for cross-origin resources, the Timing-Allow-Origin response header must be set to expose the full timing breakdown (DNS, TCP, TTFB, transfer). Without it, most timing fields return zero. In microfrontend deployments where teams control their own CDNs, ensuring Timing-Allow-Origin: * (or the appropriate origin value) is set on all assets is a prerequisite for meaningful resource timing data.
Designing a Cross-Frame Instrumentation Layer
Given the constraints above, a practical cross-frame instrumentation layer has three responsibilities: clock synchronization across frame boundaries, metric forwarding from child frames to a central collector, and trace correlation to tie events in different frames into coherent user-journey spans.
Step 1: Clock Synchronization via timeOrigin Exchange
The simplest approach to cross-frame clock alignment is to have each child frame send its timeOrigin to the parent as part of its initialization handshake. The parent records this alongside the wall-clock time of receipt. From that point on, any timestamp from that child can be converted to the parent's timeline as follows:
// In the parent shell
interface FrameClockInfo {
frameId: string;
childTimeOrigin: number; // DOMHighResTimeStamp in the child's epoch
parentReceiptTime: number; // performance.now() in the parent when received
offsetMs: number; // parent timeline offset for converting child timestamps
}
const frameClocks = new Map<string, FrameClockInfo>();
function handleChildHandshake(frameId: string, childTimeOrigin: number): void {
const parentReceiptTime = performance.now();
// Child's timeOrigin as absolute epoch (ms)
const childEpoch = childTimeOrigin;
// Parent's timeOrigin as absolute epoch (ms)
const parentEpoch = performance.timeOrigin;
// Offset: how many ms to add to a child performance.now() timestamp
// to get the equivalent parent performance.now() timestamp
const offsetMs = childEpoch - parentEpoch;
frameClocks.set(frameId, {
frameId,
childTimeOrigin,
parentReceiptTime,
offsetMs,
});
}
function toParentTimeline(
frameId: string,
childTimestamp: number,
): number | null {
const clock = frameClocks.get(frameId);
if (!clock) return null;
return childTimestamp + clock.offsetMs;
}
This conversion is not perfectly accurate-there's inherent uncertainty equal to approximately half the round-trip latency of the postMessage exchange-but for performance telemetry at the granularity of milliseconds, it is precise enough.
Step 2: A Lightweight Telemetry Protocol over postMessage
The postMessage channel that microfrontend systems use for feature communication can be extended with a telemetry envelope. The key design constraint is that telemetry traffic must not interfere with feature traffic-it should be batched, low-priority, and clearly typed so the shell and child frames can route it appropriately.
// Shared type definitions (published as a shared package across MFEs)
type TelemetryEntryType =
| "mark"
| "measure"
| "lcp"
| "long-animation-frame"
| "layout-shift"
| "longtask"
| "resource";
interface TelemetryEntry {
type: TelemetryEntryType;
name: string;
startTime: number; // performance.now() in the child frame
duration: number;
detail?: Record<string, unknown>;
}
interface TelemetryBatch {
__telemetry: true; // discriminant for routing
frameId: string;
sessionId: string;
traceId?: string; // correlates with parent trace context
entries: TelemetryEntry[];
batchTime: number; // performance.now() in the child at batch send time
}
// In each child microfrontend
class FrameTelemetryReporter {
private buffer: TelemetryEntry[] = [];
private flushInterval: ReturnType<typeof setInterval>;
private observer: PerformanceObserver;
constructor(
private readonly frameId: string,
private readonly sessionId: string,
) {
this.observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this.buffer.push(this.serialize(entry));
}
});
this.observer.observe({
type: "largest-contentful-paint",
buffered: true,
});
this.observer.observe({ type: "long-animation-frame", buffered: true });
this.observer.observe({ type: "layout-shift", buffered: true });
this.observer.observe({ type: "longtask", buffered: true });
this.observer.observe({ type: "resource", buffered: true });
// Flush every 5 seconds or on page visibility change
this.flushInterval = setInterval(() => this.flush(), 5000);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.flush();
});
}
mark(name: string, detail?: Record<string, unknown>): void {
performance.mark(name);
this.buffer.push({
type: "mark",
name,
startTime: performance.now(),
duration: 0,
detail,
});
}
measure(name: string, startMark: string, endMark?: string): void {
const measure = performance.measure(name, startMark, endMark);
this.buffer.push({
type: "measure",
name,
startTime: measure.startTime,
duration: measure.duration,
});
}
private serialize(entry: PerformanceEntry): TelemetryEntry {
return {
type: entry.entryType as TelemetryEntryType,
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
};
}
private flush(): void {
if (this.buffer.length === 0) return;
const batch: TelemetryBatch = {
__telemetry: true,
frameId: this.frameId,
sessionId: this.sessionId,
entries: [...this.buffer],
batchTime: performance.now(),
};
this.buffer = [];
window.parent.postMessage(batch, "*"); // tighten origin in production
}
}
Note the use of batched flushing rather than per-event forwarding. Sending a postMessage for every PerformanceObserver entry is a common mistake that introduces back-pressure and contaminates the very metrics you're trying to measure.
Step 3: Trace Context Propagation
To correlate a user interaction in the shell ("user clicked 'Load Report'") with the LCP event in a child iframe that subsequently rendered the report data, you need a shared trace context. The W3C traceparent header format, used in distributed tracing systems like OpenTelemetry, provides a portable model: a 16-byte trace ID, an 8-byte parent span ID, and a flags byte.
// Minimal W3C-compatible trace context for in-browser use
interface TraceContext {
traceId: string; // 32 hex chars
spanId: string; // 16 hex chars
sampled: boolean;
}
function generateTraceId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
function generateSpanId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(8));
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
// When the shell dispatches a message to a child that starts a new user flow,
// attach the current trace context. The child records it and includes
// it in all subsequent telemetry batches.
function dispatchWithTrace(
iframe: HTMLIFrameElement,
message: unknown,
traceContext: TraceContext,
): void {
iframe.contentWindow?.postMessage(
{
...(message as object),
__traceContext: traceContext,
},
"https://child-mfe.example.com", // always specify target origin
);
}
With trace context propagated into each child, the telemetry collector in the shell can stitch together a synthetic distributed trace: the shell's own span covers user interaction to message dispatch; the child's span covers message receipt to LCP. The gap between them-shell dispatch time to child receipt time, converted via the timeOrigin offset-represents the postMessage delivery latency.
Practical Implementation: The Shell Telemetry Collector
The shell application acts as the telemetry aggregator. It receives batches from all child frames, applies clock corrections, correlates spans by trace ID, and forwards the assembled data to your observability backend (a real user monitoring platform, a custom analytics endpoint, or a service like OpenTelemetry Collector running at the edge).
// Shell-side telemetry collector
interface NormalizedEntry extends TelemetryEntry {
frameId: string;
sessionId: string;
traceId?: string;
absoluteStartTime: number; // wall-clock ms since epoch
parentTimelineStart: number; // parent performance.now() equivalent
}
class ShellTelemetryCollector {
private frameClocks = new Map<string, FrameClockInfo>();
private pendingSpans = new Map<string, NormalizedEntry[]>(); // keyed by traceId
constructor(private readonly beaconUrl: string) {
window.addEventListener("message", this.handleMessage.bind(this));
}
private handleMessage(event: MessageEvent): void {
// Only accept from known child origins in production
const data = event.data;
if (data?.__telemetry !== true) return; // not a telemetry message
const batch = data as TelemetryBatch;
const clock = this.frameClocks.get(batch.frameId);
if (!clock) {
// Frame clock not yet registered; buffer or discard
console.warn(
`[Telemetry] No clock registered for frame: ${batch.frameId}`,
);
return;
}
const normalized = batch.entries.map((entry): NormalizedEntry => {
const parentTimeline = toParentTimeline(batch.frameId, entry.startTime)!;
return {
...entry,
frameId: batch.frameId,
sessionId: batch.sessionId,
traceId: batch.traceId,
absoluteStartTime: performance.timeOrigin + parentTimeline,
parentTimelineStart: parentTimeline,
};
});
if (batch.traceId) {
const existing = this.pendingSpans.get(batch.traceId) ?? [];
this.pendingSpans.set(batch.traceId, [...existing, ...normalized]);
}
this.flush(normalized);
}
registerFrameClock(frameId: string, childTimeOrigin: number): void {
handleChildHandshake(frameId, childTimeOrigin); // defined earlier
// Copy into local map for collector use
const clock = frameClocks.get(frameId)!;
this.frameClocks.set(frameId, clock);
}
private flush(entries: NormalizedEntry[]): void {
if (entries.length === 0) return;
navigator.sendBeacon(
this.beaconUrl,
JSON.stringify({ source: "shell-telemetry", entries }),
);
}
}
navigator.sendBeacon is the correct mechanism for forwarding telemetry to your backend. Unlike fetch, it is guaranteed to complete even if the page is unloading, which is critical for capturing metrics that are only finalized at session end (cumulative layout shift, total blocking time).
Measuring Interaction-to-Next-Paint Across Frames
One of the most important metrics in modern performance measurement is Interaction to Next Paint (INP), the Core Web Vital that replaced First Input Delay in March 2024. INP measures the worst-case responsiveness of a page to user interactions across the entire session, not just on initial load.
In a nested iframe architecture, interactions within child frames contribute to that frame's INP-not the parent's. A slow button handler inside a child MFE will show up in the child's PerformanceObserver INP data, not in a Lighthouse audit of the parent shell. This is a critical blind spot: your shell might score green on Core Web Vitals while the actual user experience in the embedded fragments is degraded.
The fix is to instrument INP inside every child frame and forward it as a named measure entry through the telemetry pipeline. The most accurate approach uses the event entry type, available in PerformanceObserver:
// Inside each child microfrontend - INP candidate tracking
const interactionDurations: number[] = [];
const eventObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as PerformanceEventTiming[]) {
// Only count interactions (pointer events, keyboard)
if (entry.interactionId > 0) {
interactionDurations.push(entry.duration);
reporter.mark("interaction", {
interactionId: entry.interactionId,
duration: entry.duration,
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
});
}
}
});
eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
// At session end, compute INP (98th percentile)
function computeINP(): number {
if (interactionDurations.length === 0) return 0;
const sorted = [...interactionDurations].sort((a, b) => a - b);
const idx = Math.ceil(sorted.length * 0.98) - 1;
return sorted[Math.max(0, idx)];
}
Trade-offs and Pitfalls
The instrumentation architecture described above has real costs. Understanding them helps you calibrate how much measurement overhead is acceptable and where to cut scope.
postMessage Overhead and Measurement Contamination
Every telemetry batch sent via postMessage is itself a source of performance overhead. The serialization of the message payload (structured clone or, in some implementations, JSON serialization) runs on the child's main thread. The message delivery runs on the parent's main thread. If your telemetry system generates high-frequency events-say, resource timing entries for every request made by a data-heavy child-the volume of postMessage calls can itself cause long tasks, inflating the very latency metrics you're trying to measure.
The solution is aggressive batching (as shown above) combined with sampling. For longtask and long-animation-frame entries, report all occurrences-these are by definition infrequent (each is at least 50ms of main thread work). For resource timing, apply a sampling rate of 10-25% in high-traffic applications. For custom marks and measures added by application code, report all of them-they're sparse by design.
Clock Skew Accumulation in Long Sessions
The timeOrigin exchange protocol gives you a fixed offset computed once at frame initialization. In very long sessions-or in applications that destroy and recreate iframes dynamically-this offset can drift or become invalid. If a child iframe navigates (full page navigation, not SPA transition), its timeOrigin changes. The parent must handle a re-initialization handshake or treat subsequent telemetry from that frame as uncorrelated.
A robust implementation includes a generation counter in the telemetry envelope: each time a child frame initializes, it increments its generation and resends its timeOrigin. The shell collector rejects entries from old generations once a new one is received, preventing stale clock offsets from corrupting metrics.
The "Missing Frame" Problem in Sparse Navigation
In microfrontend systems where not all child frames are loaded on every page, your telemetry schema must handle sparse data gracefully. If you build a dashboard that assumes all four MFEs send INP data on every session, and two of them are only loaded for 5% of users, your aggregation logic will show misleading INP distributions for those frames. Mark every telemetry entry with the frame's URL and whether it was visible (via IntersectionObserver) at the time of measurement.
Security Considerations in postMessage-Based Telemetry
The telemetry pipeline described here uses postMessage with a wildcard target origin in examples-this is acceptable for demonstration but wrong for production. Always specify the exact target origin when posting from parent to child, and always validate event.origin when receiving in the parent. A child MFE from an unintended origin should never be able to inject fake telemetry into your collector, as this could corrupt RUM dashboards or-in systems that feed performance data back into routing or caching decisions-cause operational problems.
Additionally, avoid forwarding personally identifiable information through the telemetry channel. Custom mark detail fields should contain structural names and timing identifiers, not user data or content.
Best Practices
These recommendations consolidate the patterns above into actionable guidance for teams building or instrumenting iframe-based microfrontend systems.
Establish a shared telemetry contract early. The types defining TelemetryBatch, TraceContext, and FrameClockInfo should live in a shared package installed by every MFE. When teams diverge on telemetry format, the shell collector becomes an incompatible mess. Treat the telemetry protocol as a first-class API with a changelog and versioning.
Measure from the user's perspective, not the implementation's. The most important metrics are those that correlate with user outcomes: INP (responsiveness), LCP (load speed), and cumulative layout shift (visual stability). These should be instrumented in every frame. Internal metrics like postMessage round-trip time are useful for debugging but should not drive optimization priorities.
Use Chromium's performance.measureUserAgentSpecificMemory() carefully. This API, available in cross-origin isolated contexts, can measure the memory usage of individual iframes. It is powerful for diagnosing memory bloat in child frames but requires the Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers on the shell, which affects what resources can be loaded. Do not enable cross-origin isolation for telemetry alone unless you are prepared to audit and update all embedded content.
Instrument the postMessage channel itself. Treat postMessage calls as RPC calls: record the send time on the sender side and the receive time on the receiver side (using the timeOrigin offset for cross-frame comparison). This lets you directly measure the message delivery latency distribution-typically 0-2ms but spiking to 10-50ms under main thread pressure. This data is invaluable for detecting contention and over-communication.
// Wrapper for instrumented postMessage dispatch
function instrumentedPostMessage(
target: Window,
targetOrigin: string,
message: object,
traceContext?: TraceContext,
): void {
const sendTime = performance.now();
const correlationId = generateSpanId();
target.postMessage(
{
...message,
__correlationId: correlationId,
__sendTime: sendTime,
__traceContext: traceContext,
},
targetOrigin,
);
performance.mark(`postMessage:send:${correlationId}`);
}
// On the receiver side (child frame)
window.addEventListener("message", (event: MessageEvent) => {
const receiveTime = performance.now();
const { __correlationId, __sendTime, ...payload } = event.data ?? {};
if (__correlationId) {
performance.mark(`postMessage:receive:${__correlationId}`);
// Forward delivery latency as a telemetry mark (using sender's timeline)
// The shell collector will normalize this using timeOrigin offset
reporter.mark("postMessage:delivery", {
correlationId: __correlationId,
receiveTime,
});
}
handleFeatureMessage(payload);
});
Integrate with your CI pipeline. Frame-level performance metrics should be tracked in CI using tools like Playwright's page.metrics() combined with frame-specific performance evaluation via frame.evaluate(() => performance.getEntriesByType(...)). Regressions in child frame LCP or INP should block deployment just as regressions in shell-level metrics do.
Key Takeaways
Five steps you can apply immediately to your microfrontend performance practice:
-
Run a
PerformanceObserverin every iframe, not just the shell. Instrumentlong-animation-frame,largest-contentful-paint, andevententry types in each child and forward them via a batched telemetry protocol. -
Exchange
timeOriginvalues at frame initialization to enable cross-frame timestamp normalization. Without this, any latency measurement that crosses a frame boundary is unreliable. -
Propagate W3C trace context on feature messages that initiate user flows. This allows you to correlate shell interaction events with downstream rendering events in child frames into coherent distributed traces.
-
Instrument postMessage calls as RPC spans. Measure send-to-receive latency for your inter-frame communication channel. This is the most direct indicator of main thread contention and over-communication between frames.
-
Verify INP in every child frame independently. Core Web Vitals tooling measures the shell document; it will not surface slow interactions inside cross-origin child iframes. You need explicit child-side event timing observation and aggregated reporting to see the complete picture.
80/20 Insight
If you had to prioritize one thing in cross-frame performance measurement, it is this: get timeOrigin exchange and batched PerformanceObserver forwarding working before you instrument anything else. Without those two foundations, every other metric you collect-INP, LCP, postMessage latency-is either unmeasurable or unreliable. The clock synchronization gives you a shared timeline. The batched observer gives you a low-overhead pipeline that doesn't perturb the system it measures. Everything else is refinement on top.
The other 20% that produces most of the remaining results is trace context propagation. Once your shell can correlate a user click with the LCP in the child frame that responded to it, you have the basis for real root cause analysis. Without that correlation, you have a collection of metrics from different frames that you can analyze individually but cannot reason about together.
Analogies and Mental Models
Think of your microfrontend system as a distributed system running in a single browser tab. Each iframe is a service replica running in its own process (or near-process boundary). The postMessage channel is your internal RPC mechanism. The parent shell is your API gateway. With this mental model, all the distributed systems observability patterns apply directly: you need distributed tracing (trace context), clock synchronization (timeOrigin exchange), per-service metrics (per-frame PerformanceObserver), and a central aggregation layer (ShellTelemetryCollector).
The clock skew problem maps cleanly to the Network Time Protocol (NTP) challenge: independent clocks drift relative to each other, and any measurement that spans clocks requires a synchronized reference. The timeOrigin offset method described here is analogous to a one-shot NTP sync-accurate enough for most purposes, but imprecise by the latency of the synchronization exchange itself.
Conclusion
Microfrontend architectures with nested iframes enable real organizational benefits: team autonomy, independent deployment, and technology heterogeneity. But they come with genuine observability costs that are easy to underestimate. The browser's performance primitives stop at the frame boundary. The tools engineers reach for by default-Lighthouse, DevTools, synthetic monitoring-give an incomplete picture when the user experience is assembled from multiple independent browsing contexts.
The architecture presented here-timeOrigin exchange for clock normalization, batched PerformanceObserver forwarding for metric collection, W3C trace context for cross-frame correlation, and sendBeacon for reliable upstream delivery-is grounded in existing browser standards and requires no proprietary instrumentation libraries. It can be incrementally adopted: start with clock exchange and bulk metric forwarding, add trace propagation for the user flows that matter most, and expand from there.
The most important principle is to treat inter-frame communication as an observable system boundary from day one. Every postMessage is a potential latency source, a potential contention point, and a trace span waiting to be measured. Building that discipline into your microfrontend platform early is far cheaper than retrofitting it after you've shipped five teams' worth of independently developed message protocols.
Performance in distributed frontend systems is not a feature you add later. It is an architectural concern that must be reflected in your frame communication contracts, your shared telemetry libraries, and your CI gates-from the beginning.
References
- W3C Performance Timeline API Specification - https://www.w3.org/TR/performance-timeline/
- W3C High Resolution Time Level 3 - https://www.w3.org/TR/hr-time-3/
- MDN Web Docs: PerformanceObserver - https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver
- MDN Web Docs: Window.postMessage() - https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
- Long Animation Frames API Explainer - https://github.com/nicowillis/long-animation-frames/blob/main/explainer.md
- W3C Trace Context Specification (traceparent header) - https://www.w3.org/TR/trace-context/
- Google Web.dev: INP (Interaction to Next Paint) - https://web.dev/articles/inp
- Google Web.dev: User-centric performance metrics - https://web.dev/articles/user-centric-performance-metrics
- WHATWG HTML Living Standard: Browsing Contexts - https://html.spec.whatwg.org/multipage/browsers.html#browsing-contexts
- MDN Web Docs: navigator.sendBeacon() - https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
- Micro Frontends in Action - Michael Geers, Manning Publications, 2020
- Chrome Developer Blog: Cross-origin isolation guide - https://developer.chrome.com/blog/enabling-shared-array-buffer/
- MDN Web Docs: performance.measureUserAgentSpecificMemory() - https://developer.mozilla.org/en-US/docs/Web/API/Performance/measureUserAgentSpecificMemory
- OpenTelemetry JavaScript SDK Documentation - https://opentelemetry.io/docs/languages/js/
- W3C Resource Timing Level 2 - https://www.w3.org/TR/resource-timing-2/