Interaktions- und Soft-Navigations-Paint-Timing messen

Bei Standardmesswerten für das Rendern und die Reaktionsfähigkeit gibt es nach dem ersten Seitenaufbau eine Messlücke:

  • LargestContentfulPaint (largest-contentful-paint) misst nur das anfängliche („harte“) Laden des Dokuments und gibt keine Einträge mehr aus, sobald der Nutzer zum ersten Mal mit der Seite interagiert.
  • PerformanceEventTiming und event / Interaction to Next Paint (INP) misst die Reaktionsfähigkeit auf Eingaben bis zum nächsten Frame (z. B. ein aktiver Button oder ein Ladesymbol), erfasst aber keine nachfolgenden asynchronen Paint-Vorgänge, z. B. neue Inhalte, die nach der Auflösung eines fetch() gerendert werden, oder den Abschluss eines clientseitigen Routenübergangs.
  • Das Beobachten von Routenänderungen in einer Single-Page-Anwendung (SPA) kann außerhalb des Framework-Codes schwierig sein. Wenn Sie nur URL-Aktualisierungen beobachten, werden möglicherweise zu viele URL-Änderungen gemeldet, die nicht mit Routenänderungen zusammenhängen (z. B. Änderungen an Ankerlinks, mehrere „Weiterleitungen“ für eine einzelne Routenänderung).

Die Leistungseintragstypen InteractionContentfulPaint (interaction-contentful-paint) und PerformanceSoftNavigation (soft-navigation) schließen diese Lücke. Sie geben die Kausalität von Interaktionen über asynchrone Aufgaben (fetch, await, setTimeout) weiter, um neue Contentful Paints und Routenübergänge in Single-Page-Anwendungen (SPA) direkt der Nutzerinteraktion zuzuordnen, die sie ausgelöst hat.

Implementierung

1. Durch Interaktionen ausgelöste Contentful Paints beobachten

Verwenden Sie einen PerformanceObserver mit type: 'interaction-contentful-paint' und buffered: true, um zunehmend größere Contentful Paints zu beobachten, die auf Nutzerinteraktionen zurückzuführen sind.

Jeder InteractionContentfulPaint-Eintrag enthält:

  • interactionId: Die eindeutige Kennung der initiierenden Nutzerinteraktion (entspricht PerformanceEventTiming.interactionId und PerformanceSoftNavigation.interactionId).
  • startTime: Der Zeitstempel, der angibt, wann die initiierende Nutzerinteraktion begonnen hat, relativ zum ursprünglichen Hard-Navigation-Zeitursprung.
  • paintTime und presentationTime: Wann die Renderingphase endete und wann die gerenderten Pixel auf dem Bildschirm angezeigt wurden.
  • duration: Die verstrichene Zeit vom Beginn der Interaktion bis zur Darstellung des Paints (presentationTime - startTime).
  • largestContentfulPaint: Ein Snapshot-LargestContentfulPaint-Objekt mit Details zum größten gerenderten Element für diese Interaktion bis jetzt (element, size, url, id, renderTime, loadTime).
/**
 * Observes contentful paints caused by user interactions and invokes a callback
 * whenever an interaction produces a larger contentful paint.
 *
 * @param {(entry: PerformanceEntry) => void} onInteractionPaint
 */
export function observeInteractionPaints(onInteractionPaint) {
  // Feature-detect support before calling observe() so unsupported browsers
  // do not throw or log console warnings.
  if (
    typeof PerformanceObserver === 'undefined' ||
    !PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint')
  ) {
    return null;
  }

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // entry.duration is already calculated as (entry.presentationTime - entry.startTime),
      // giving the total time from the user interaction to this contentful paint.
      onInteractionPaint(entry);
    }
  });

  // MANDATORY: Pass buffered: true to capture interaction paints that occurred
  // before this observer was registered.
  observer.observe({ type: 'interaction-contentful-paint', buffered: true });
  return observer;
}

2. Largest Contentful Paint (LCP) für Soft-Navigationen in SPAs messen

Eine Soft-Navigation wird vom Browser ausgegeben, wenn eine vertrauenswürdige Nutzerinteraktion sowohl eine sichtbare URL-Änderung als auch ein Contentful Paint verursacht. So berechnen Sie den LCP für jede weiche Navigation:

  1. Beobachten Sie sowohl 'soft-navigation'- als auch 'interaction-contentful-paint'-Einträge mit buffered: true.
  2. OBLIGATORISCH: Wenn ein 'soft-navigation'-Eintrag empfangen wird, wird der erste LCP-Kandidat durch Aufrufen von softNavEntry.getLargestInteractionContentfulPaint() festgelegt. Wenn die Anwendung Inhalte vor der Aktualisierung der URL gerendert hat (z. B. eine Ansicht gerendert und dann history.pushState() aufgerufen hat), wurden diese frühen interaction-contentful-paint-Einträge vor dem soft-navigation-Eintrag ausgegeben und enthalten noch den vorherigen navigationId.
  3. OBLIGATORISCH: Korreliere nachfolgende 'interaction-contentful-paint'-Einträge mit der aktiven weichen Navigation, indem du entry.interactionId === softNavEntry.interactionId abgleichst. NICHT nach entry.navigationId abgleichen. Mit interactionId werden Paint-Ereignisse berücksichtigt, die vor der URL-Aktualisierung aufgetreten sind, und Paint-Ereignisse von späteren, nicht damit zusammenhängenden Interaktionen auf der neuen Route ausgeschlossen.
/**
 * Tracks FCP and LCP for each client-side soft navigation and reports finalized
 * route metrics when the user navigates to a new route or hides the page.
 *
 * @param {(report: {
 *   url: string,
 *   navigationId: number,
 *   interactionId: number,
 *   fcpDuration: number,
 *   lcpDuration: number | null,
 *   lcpElement: Element | null,
 *   lcpSize: number | null
 * }) => void} onSoftNavReport
 */
export function trackSoftNavigationLCP(onSoftNavReport) {
  const supported =
    typeof PerformanceObserver !== 'undefined' &&
    PerformanceObserver.supportedEntryTypes?.includes('soft-navigation') &&
    PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint');

  if (!supported) {
    return null;
  }

  let activeSoftNav = null;
  let activeLcpEntry = null;

  function finalizeCurrentSoftNav() {
    if (!activeSoftNav) return;

    onSoftNavReport({
      // activeSoftNav.name holds the destination URL of the soft navigation.
      url: activeSoftNav.name,
      navigationId: activeSoftNav.navigationId,
      interactionId: activeSoftNav.interactionId,
      // activeSoftNav.duration is (activeSoftNav.presentationTime - activeSoftNav.startTime),
      // which represents First Contentful Paint (FCP) relative to interaction start.
      fcpDuration: activeSoftNav.duration,
      // activeLcpEntry.duration is (activeLcpEntry.presentationTime - activeSoftNav.startTime),
      // representing the soft navigation's LCP duration relative to interaction start.
      lcpDuration: activeLcpEntry ? activeLcpEntry.duration : null,
      lcpElement: activeLcpEntry?.largestContentfulPaint?.element ?? null,
      lcpSize: activeLcpEntry?.largestContentfulPaint?.size ?? null,
    });

    activeSoftNav = null;
    activeLcpEntry = null;
  }

  const softNavObserver = new PerformanceObserver((list) => {
    for (const softNavEntry of list.getEntries()) {
      // Finalize the previous route's metrics before resetting state for the new route.
      finalizeCurrentSoftNav();

      activeSoftNav = softNavEntry;
      // MANDATORY: Seed the LCP candidate with getLargestInteractionContentfulPaint()
      // to capture any attributed contentful paints that occurred before the URL updated.
      activeLcpEntry = softNavEntry.getLargestInteractionContentfulPaint();
    }
  });

  const icpObserver = new PerformanceObserver((list) => {
    for (const icpEntry of list.getEntries()) {
      // MANDATORY: Match by interactionId (not navigationId) so only paints caused
      // by the soft navigation's initiating interaction update the route's LCP.
      if (activeSoftNav && icpEntry.interactionId === activeSoftNav.interactionId) {
        activeLcpEntry = icpEntry;
      }
    }
  });

  softNavObserver.observe({ type: 'soft-navigation', buffered: true });
  icpObserver.observe({ type: 'interaction-contentful-paint', buffered: true });

  // Finalize the active route's LCP when the page is backgrounded or unloaded.
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      finalizeCurrentSoftNav();
    }
  });

  return { softNavObserver, icpObserver, finalizeCurrentSoftNav };
}

3. Core Web Vitals bei Soft-Navigationen mit web-vitals messen

Wenn Sie die standardmäßigen Core Web Vitals (LCP, CLS und INP) für harte und weiche Navigationen an einen Analyseendpunkt melden möchten, verwenden Sie die web-vitals-Bibliothek (v6.0.0+) mit { reportSoftNavs: true }, anstatt jede Messwert-Zeitachse manuell zu segmentieren.

import { onLCP, onFCP, onCLS, onINP } from 'web-vitals';

function sendToAnalytics(metric) {
  // Example-only endpoint; replace with your real telemetry URL.
  navigator.sendBeacon(
    '/analytics',
    JSON.stringify({
      name: metric.name,
      value: metric.value,
      navigationType: metric.navigationType,
      navigationURL: metric.navigationURL,
    }),
  );
}

// Pass { reportSoftNavs: true } to report metrics per soft navigation in addition
// to the initial hard navigation.
onLCP(sendToAnalytics, { reportSoftNavs: true });
onFCP(sendToAnalytics, { reportSoftNavs: true });
onCLS(sendToAnalytics, { reportSoftNavs: true });
onINP(sendToAnalytics, { reportSoftNavs: true });

Best Practices

  • FÜHREN SIE eine Feature-Erkennung für 'interaction-contentful-paint' und 'soft-navigation' mit PerformanceObserver.supportedEntryTypes?.includes(...) durch, bevor Sie observer.observe() aufrufen.
  • Übergib { buffered: true }, wenn du 'interaction-contentful-paint' und 'soft-navigation' beobachtest, damit frühe Einträge nicht verpasst werden, wenn der Analysecode asynchron initialisiert wird.
  • RUFEN SIE softNavEntry.getLargestInteractionContentfulPaint() auf, wenn Sie einen neuen soft-navigation-Eintrag verarbeiten, damit Paint-Ereignisse, die vor der URL-Aktualisierung aufgetreten sind, in den LCP einbezogen werden.
  • DO match interaction-contentful-paint entries to a soft navigation using interactionId, not navigationId.
  • DO use entry.duration (or entry.presentationTime - entry.startTime) to measure interaction paint latency, rather than reporting entry.presentationTime directly. Alle PerformanceEntry-Zeitstempel werden ab dem ursprünglichen harten Seitenladezeitpunkt gemessen, nicht ab dem Start der weichen Navigation.
  • Verlassen Sie sich NICHT auf performance.getEntriesByType('soft-navigation'), um langlebige Single-Page-Anwendungen zu beobachten, da der Puffer des Browsers auf die ersten 50 Soft-Navigationseinträge beschränkt ist.

Fallback-Strategien

Wenn Ihre Baseline-Zielgruppe Browser umfasst, die InteractionContentfulPaint oder PerformanceSoftNavigation nicht unterstützen, sollten Sie diese APIs als progressive Verbesserung für das Real User Monitoring (RUM) behandeln:

  1. Featureerkennung und graduelle Fehlertoleranz: Schützen Sie die Observer-Registrierung mit PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint') und PerformanceObserver.supportedEntryTypes?.includes('soft-navigation'). In Browsern, die diese Funktion nicht unterstützen, sollten Sie die Observer nicht registrieren, damit Ihre Anwendung ohne Fehler ausgeführt wird.
  2. Standard-Hard-Navigations-Observer aktiv lassen: Beobachten Sie weiterhin die Standardeinträge 'largest-contentful-paint', 'event' und 'layout-shift' in allen Browsern. Da LargestContentfulPaint unabhängig von der Unterstützung von Soft-Navigationen bei der ersten Nutzerinteraktion abgeschlossen wird, werden die ursprünglichen LCP-Messungen für Hard-Loads durch die Messung von Soft-Navigationen in unterstützenden Browsern nicht verändert oder beeinträchtigt.