Misurare i tempi di visualizzazione delle interazioni e della navigazione soft

Le metriche standard di visualizzazione e reattività lasciano un gap di misurazione dopo il caricamento iniziale della pagina:

  • LargestContentfulPaint (largest-contentful-paint) misura solo il caricamento iniziale ("hard") del documento e interrompe l'emissione di voci non appena l'utente interagisce per la prima volta con la pagina.
  • PerformanceEventTiming e event/Interaction to Next Paint (INP) misura la reattività degli input fino al frame successivo (ad esempio lo stato attivo di un pulsante o l'indicatore di caricamento), ma non monitora i paint asincroni successivi, come il rendering di nuovi contenuti dopo la risoluzione di un fetch() o il completamento di una transizione di route lato client.
  • Osservare le modifiche alle route in un'applicazione a pagina singola (SPA) può essere difficile al di fuori del codice del framework. Il monitoraggio dei soli aggiornamenti degli URL può generare un numero eccessivo di modifiche degli URL non collegate a modifiche dei percorsi (ad esempio, modifiche dei link di ancoraggio, più "reindirizzamenti" per una singola modifica del percorso).

I tipi di voci di rendimento InteractionContentfulPaint (interaction-contentful-paint) e PerformanceSoftNavigation (soft-navigation) colmano questa lacuna. Propagano la causalità dell'interazione tra attività asincrone (fetch, await, setTimeout) per attribuire i nuovi paint con contenuti e le transizioni di route delle applicazioni a pagina singola (SPA) direttamente all'interazione dell'utente che le ha attivate.

Come implementare

1. Osserva i paint con contenuti attivati dalle interazioni

Utilizza un PerformanceObserver con type: 'interaction-contentful-paint' e buffered: true per osservare i Largest Contentful Paint progressivamente più grandi attribuibili a qualsiasi interazione dell'utente.

Ogni voce InteractionContentfulPaint fornisce:

  • interactionId: l'identificatore univoco dell'interazione utente iniziale (corrispondente a PerformanceEventTiming.interactionId e PerformanceSoftNavigation.interactionId).
  • startTime: il timestamp dell'inizio dell'interazione utente iniziale, relativo all'origine del tempo di navigazione iniziale.
  • paintTime e presentationTime: quando è terminata la fase di rendering e quando i pixel colorati sono stati disegnati sullo schermo.
  • duration: il tempo trascorso dall'inizio dell'interazione alla presentazione del paint (presentationTime - startTime).
  • largestContentfulPaint: un oggetto snapshot LargestContentfulPaint contenente i dettagli relativi all'elemento visualizzato più grande per l'interazione finora (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. Misura il Largest Contentful Paint (LCP) per le navigazioni soft delle SPA

Una navigazione parziale viene attivata dal browser quando un'interazione utente attendibile causa sia una modifica visibile dell'URL sia un Contentful Paint. Per calcolare l'LCP per ogni navigazione soft:

  1. Osserva le voci 'soft-navigation' e 'interaction-contentful-paint' con buffered: true.
  2. OBBLIGATORIO: quando viene ricevuta una voce 'soft-navigation', genera il candidato LCP iniziale chiamando softNavEntry.getLargestInteractionContentfulPaint(). Se l'applicazione ha visualizzato i contenuti prima di aggiornare l'URL (ad esempio, eseguendo il rendering di una visualizzazione e poi chiamando history.pushState()), le voci interaction-contentful-paint precedenti sono state emesse prima della voce soft-navigation e contengono ancora il navigationId precedente.
  3. OBBLIGATORIO: correla le voci 'interaction-contentful-paint' successive alla navigazione soft attiva abbinando entry.interactionId === softNavEntry.interactionId, NON abbinare per entry.navigationId. L'utilizzo di interactionId garantisce l'inclusione dei paint avvenuti prima dell'aggiornamento dell'URL e l'esclusione dei paint di interazioni successive non correlate sul nuovo percorso.
/**
 * 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. Misura i Core Web Vitals nelle navigazioni soft con web-vitals

Se il tuo obiettivo è generare report sui Core Web Vitals standard (LCP, CLS e INP) sia per le navigazioni hard che per quelle soft in un endpoint di analisi, utilizza la libreria web-vitals (v6.0.0+) con { reportSoftNavs: true } anziché suddividere manualmente ogni sequenza temporale delle metriche.

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 practice

  • ESEGUI il rilevamento delle funzionalità 'interaction-contentful-paint' e 'soft-navigation' utilizzando PerformanceObserver.supportedEntryTypes?.includes(...) prima di chiamare observer.observe().
  • DO pass { buffered: true } when observing 'interaction-contentful-paint' and 'soft-navigation' so early entries are not missed if analytics code initializes asynchronously.
  • CHIAMA softNavEntry.getLargestInteractionContentfulPaint() quando gestisci una nuova voce soft-navigation in modo che i paint che si sono verificati prima dell'aggiornamento dell'URL siano inclusi nell'LCP.
  • PUOI abbinare le voci interaction-contentful-paint a una navigazione soft utilizzando interactionId, non navigationId.
  • SÌ: utilizza entry.duration (o entry.presentationTime - entry.startTime) per misurare la latenza di visualizzazione dell'interazione, anziché segnalare direttamente entry.presentationTime. Tutti i timestamp di PerformanceEntry vengono misurati dall'origine del tempo di caricamento della pagina iniziale, non dall'inizio della navigazione soft.
  • NON fare affidamento su performance.getEntriesByType('soft-navigation') per monitorare le applicazioni a pagina singola di lunga durata, poiché il buffer del browser è limitato alle prime 50 voci di navigazione soft.

Strategie di riserva

Se il tuo target Baseline include browser che non supportano InteractionContentfulPaint o PerformanceSoftNavigation, considera queste API come un potenziamento progressivo per il monitoraggio degli utenti reali (RUM):

  1. Rilevamento delle funzionalità e riduzione controllata: proteggi la registrazione dell'observer con PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint') e PerformanceObserver.supportedEntryTypes?.includes('soft-navigation'). Nei browser che non supportano questa funzionalità, salta la registrazione degli osservatori in modo che l'applicazione venga eseguita senza generare errori.
  2. Mantieni attivi gli osservatori di navigazione standard: continua a osservare le voci standard 'largest-contentful-paint', 'event' e 'layout-shift' in tutti i browser. Poiché LargestContentfulPaint viene finalizzato alla prima interazione dell'utente, indipendentemente dal supporto delle navigazioni flessibili, la misurazione delle navigazioni flessibili nei browser che le supportano non altera né interrompe le misurazioni LCP iniziali del caricamento completo.