Medir o tempo de pintura de interação e de navegação suave

As métricas padrão de pintura e responsividade deixam uma lacuna de medição após o carregamento de página inicial:

  • LargestContentfulPaint (largest-contentful-paint) mede apenas o carregamento inicial ("hard") do documento e para de emitir entradas assim que o usuário interage com a página pela primeira vez.
  • PerformanceEventTiming e event / Interaction to Next Paint (INP) medem a capacidade de resposta de entrada até o próximo frame (como um estado ativo de botão ou um ícone de carregamento), mas não rastreiam as pinturas assíncronas subsequentes, como um novo conteúdo renderizado após a resolução de um fetch() ou a conclusão de uma transição de rota do lado do cliente.
  • Observar mudanças de rota em um aplicativo de página única (SPA) pode ser difícil fora do código do framework. Monitorar apenas as atualizações de URL pode gerar relatórios excessivos de mudanças de URL que não estão vinculadas a mudanças de rota (por exemplo, mudanças de link fixo, vários "redirecionamentos" para uma única mudança de rota).

Os tipos de entrada de performance InteractionContentfulPaint (interaction-contentful-paint) e PerformanceSoftNavigation (soft-navigation) preenchem essa lacuna. Eles propagam a causalidade da interação em tarefas assíncronas (fetch, await, setTimeout) para atribuir novas renderizações de conteúdo e transições de rota de aplicativos de página única (SPA) diretamente à interação do usuário que as acionou.

Como fazer a implementação

1. Observar as Contentful Paints acionadas por interações

Use um PerformanceObserver com type: 'interaction-contentful-paint' e buffered: true para observar as maiores pinturas com conteúdo progressivamente atribuíveis a qualquer interação do usuário.

Cada entrada InteractionContentfulPaint fornece:

  • interactionId: o identificador exclusivo da interação do usuário que iniciou o evento (correspondente a PerformanceEventTiming.interactionId e PerformanceSoftNavigation.interactionId).
  • startTime: o carimbo de data/hora de quando a interação do usuário que iniciou a navegação começou, em relação à origem do tempo de navegação inicial.
  • paintTime e presentationTime: quando a fase de renderização terminou e quando os pixels pintados foram desenhados na tela.
  • duration: o tempo decorrido do início da interação até a apresentação da pintura (presentationTime - startTime).
  • largestContentfulPaint: um objeto de snapshot LargestContentfulPaint que contém detalhes sobre o maior elemento pintado para essa interação até o 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. Medir a Largest Contentful Paint (LCP) para navegações suaves de SPA

Uma navegação suave é emitida pelo navegador quando uma interação confiável do usuário causa uma mudança visível no URL e uma exibição de conteúdo. Para calcular a LCP de cada navegação flexível:

  1. Observe as entradas 'soft-navigation' e 'interaction-contentful-paint' com buffered: true.
  2. OBRIGATÓRIO: quando uma entrada 'soft-navigation' for recebida, use softNavEntry.getLargestInteractionContentfulPaint() para definir o candidato inicial da LCP. Se o aplicativo tiver pintado o conteúdo antes de atualizar o URL (por exemplo, renderizando uma visualização e chamando history.pushState()), as entradas interaction-contentful-paint iniciais foram emitidas antes da entrada soft-navigation e ainda carregam o navigationId anterior.
  3. OBRIGATÓRIO: correlacione as entradas 'interaction-contentful-paint' subsequentes à navegação flexível ativa correspondendo a entry.interactionId === softNavEntry.interactionId, NÃO corresponda por entry.navigationId. O uso de interactionId garante que você inclua as pinturas que ocorreram antes da atualização do URL e exclua as pinturas de interações posteriores não relacionadas na nova rota.
/**
 * 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. Medir as Core Web Vitals em navegações suaves com web-vitals

Se o objetivo é informar as Core Web Vitals padrão (LCP, CLS e INP) em navegações rígidas e flexíveis para um endpoint de análise, use a biblioteca web-vitals (v6.0.0+) com { reportSoftNavs: true } em vez de dividir manualmente cada linha do tempo de métrica.

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áticas recomendadas

  • FAÇA a detecção de recursos de 'interaction-contentful-paint' e 'soft-navigation' usando PerformanceObserver.supportedEntryTypes?.includes(...) antes de chamar observer.observe().
  • RECOMENDAMOS transmitir { buffered: true } ao observar 'interaction-contentful-paint' e 'soft-navigation' para que as entradas iniciais não sejam perdidas se o código de análise for inicializado de forma assíncrona.
  • CHAME softNavEntry.getLargestInteractionContentfulPaint() ao processar uma nova entrada soft-navigation para que as pinturas que ocorreram antes da atualização do URL sejam incluídas na LCP.
  • FAÇA a correspondência de entradas interaction-contentful-paint com uma navegação suave usando interactionId, não navigationId.
  • USE entry.duration (ou entry.presentationTime - entry.startTime) para medir a latência de pintura de interação, em vez de informar entry.presentationTime diretamente. Todos os carimbos de data/hora de PerformanceEntry são medidos a partir da origem do tempo de carregamento da página inicial, não do início da navegação suave.
  • NÃO use performance.getEntriesByType('soft-navigation') para monitorar aplicativos de página única de longa duração, já que o buffer do navegador é limitado às primeiras 50 entradas de navegação suave.

Estratégias de fallback

Se a meta do Baseline incluir navegadores que não oferecem suporte a InteractionContentfulPaint ou PerformanceSoftNavigation, trate essas APIs como um aprimoramento progressivo para o monitoramento de usuários reais (RUM, na sigla em inglês):

  1. Detecção de recursos e degradação suave: proteja o registro do observador com PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint') e PerformanceObserver.supportedEntryTypes?.includes('soft-navigation'). Em navegadores sem suporte, pule o registro dos observadores para que o aplicativo seja executado sem gerar erros.
  2. Mantenha os observadores de navegação padrão ativos: continue observando as entradas padrão 'largest-contentful-paint', 'event' e 'layout-shift' em todos os navegadores. Como a LargestContentfulPaint é finalizada na primeira interação do usuário, independente do suporte à navegação flexível, medir as navegações flexíveis em navegadores compatíveis não altera nem interrompe as medições iniciais de LCP de carregamento rígido.