Mide el tiempo de renderización de la interacción y la navegación suave

Las métricas estándar de renderización y capacidad de respuesta dejan una brecha de medición después de la carga inicial de la página:

  • LargestContentfulPaint (largest-contentful-paint) solo mide la carga inicial ("completa") del documento y deja de emitir entradas en cuanto el usuario interactúa por primera vez con la página.
  • PerformanceEventTiming y event / Interaction to Next Paint (INP) miden la capacidad de respuesta de la entrada hasta el siguiente fotograma (como un estado activo de un botón o un ícono giratorio de carga), pero no hacen un seguimiento de las pinturas asíncronas posteriores, como el contenido nuevo que se renderiza después de que se resuelve un fetch() o se completa una transición de ruta del cliente.
  • Observar los cambios de ruta en una aplicación de una sola página (SPA) puede ser difícil fuera del código del framework. Solo supervisar las actualizaciones de URL puede generar informes excesivos de cambios de URL que no están vinculados a cambios de ruta (por ejemplo, cambios de vínculos de anclaje, varios "redireccionamientos" para un solo cambio de ruta).

Los tipos de entradas de rendimiento InteractionContentfulPaint (interaction-contentful-paint) y PerformanceSoftNavigation (soft-navigation) llenan este vacío. Propagan la causalidad de la interacción en tareas asíncronas (fetch, await y setTimeout) para atribuir nuevos renderizados con contenido y transiciones de ruta de aplicaciones de una sola página (SPA) directamente a la interacción del usuario que los activó.

Cómo implementarlo

1. Observa los procesamientos de imagen con contenido que se activan con las interacciones

Usa un PerformanceObserver con type: 'interaction-contentful-paint' y buffered: true para observar los Largest Contentful Paints progresivamente más grandes que se pueden atribuir a cualquier interacción del usuario.

Cada entrada de InteractionContentfulPaint proporciona lo siguiente:

  • interactionId: Es el identificador único de la interacción del usuario que inicia la acción (coincide con PerformanceEventTiming.interactionId y PerformanceSoftNavigation.interactionId).
  • startTime: Es la marca de tiempo en la que comenzó la interacción del usuario que inició la navegación, en relación con el origen de tiempo de la navegación inicial.
  • paintTime y presentationTime: Cuándo finalizó la fase de renderización y cuándo se dibujaron los píxeles pintados en la pantalla.
  • duration: Es el tiempo transcurrido desde el inicio de la interacción hasta la presentación de la pintura (presentationTime - startTime).
  • largestContentfulPaint: Es un objeto LargestContentfulPaint de instantánea que contiene detalles sobre el elemento renderizado más grande para esa interacción hasta el momento (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. Mide el Largest Contentful Paint (LCP) para las navegaciones suaves de SPA

El navegador emite una navegación suave cuando una interacción de usuario de confianza provoca un cambio de URL visible y un procesamiento con contenido. Para calcular el LCP de cada navegación de una sola página, haz lo siguiente:

  1. Observa las entradas 'soft-navigation' y 'interaction-contentful-paint' con buffered: true.
  2. OBLIGATORIO: Cuando se reciba una entrada 'soft-navigation', llama a softNavEntry.getLargestInteractionContentfulPaint() para propagar su candidato inicial de LCP. Si la aplicación pintó contenido antes de actualizar la URL (por ejemplo, renderizando una vista y, luego, llamando a history.pushState()), esas entradas tempranas de interaction-contentful-paint se emitieron antes de la entrada de soft-navigation y aún llevan el navigationId anterior.
  3. OBLIGATORIO: Correlaciona las entradas de 'interaction-contentful-paint' posteriores con la navegación suave activa haciendo coincidir entry.interactionId === softNavEntry.interactionId, NO hagas coincidir por entry.navigationId. El uso de interactionId garantiza que incluyas las pinturas que se produjeron antes de la actualización de la URL y excluyas las pinturas de interacciones posteriores no relacionadas en la ruta nueva.
/**
 * 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. Mide las Métricas web esenciales en las navegaciones suaves con web-vitals

Si tu objetivo es informar las Core Web Vitals estándar (LCP, CLS e INP) en las navegaciones completas y parciales a un extremo de estadísticas, usa la biblioteca web-vitals (v6.0.0 o posterior) con { reportSoftNavs: true } en lugar de segmentar manualmente cada cronograma de métricas.

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 });

Prácticas recomendadas

  • SÍ detecta las funciones 'interaction-contentful-paint' y 'soft-navigation' con PerformanceObserver.supportedEntryTypes?.includes(...) antes de llamar a observer.observe().
  • SÍ pasa { buffered: true } cuando observes 'interaction-contentful-paint' y 'soft-navigation' para que no se pierdan las entradas tempranas si el código de estadísticas se inicializa de forma asíncrona.
  • SÍ llama a softNavEntry.getLargestInteractionContentfulPaint() cuando se controle una nueva entrada de soft-navigation para que los procesamientos de imagen que se produjeron antes de la actualización de la URL se incluyan en el LCP.
  • SÍ debes hacer coincidir las entradas de interaction-contentful-paint con una navegación suave usando interactionId, no navigationId.
  • SÍ uses entry.duration (o entry.presentationTime - entry.startTime) para medir la latencia de renderización de la interacción, en lugar de informar entry.presentationTime directamente. Todas las marcas de tiempo de PerformanceEntry se miden desde el origen del tiempo de carga de la página inicial completa, no desde el inicio de la navegación parcial.
  • NO confíes en performance.getEntriesByType('soft-navigation') para supervisar aplicaciones de una sola página de larga duración, ya que el búfer del navegador está limitado a las primeras 50 entradas de navegación suave.

Estrategias de resguardo

Si tu objetivo de Baseline incluye navegadores que no admiten InteractionContentfulPaint o PerformanceSoftNavigation, trata estas APIs como una mejora progresiva para la supervisión de usuarios reales (RUM):

  1. Detección de funciones y degradación elegante: Protege el registro del observador con PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint') y PerformanceObserver.supportedEntryTypes?.includes('soft-navigation'). En los navegadores que no admiten esta función, omite el registro de los observadores para que tu aplicación se ejecute sin arrojar errores.
  2. Mantén activos los observadores de navegación estándar: Sigue observando las entradas estándar de 'largest-contentful-paint', 'event' y 'layout-shift' en todos los navegadores. Debido a que el LargestContentfulPaint se finaliza en la primera interacción del usuario, independientemente de la compatibilidad con la navegación suave, medir las navegaciones suaves en los navegadores compatibles no altera ni interrumpe las mediciones iniciales del LCP de carga completa.