インタラクションとソフト ナビゲーションのペイントのタイミングを測定する

標準のペイント指標と応答性指標では、最初のページ読み込み後に測定ギャップが生じます。

  • LargestContentfulPaint(largest-contentful-paint)は、最初の(「ハード」な)ドキュメントの読み込みのみを測定し、ユーザーがページを最初に操作するとすぐにエントリの出力を停止します。
  • PerformanceEventTiming と event / Interaction to Next Paint(INP)は、次のフレーム(ボタンのアクティブ状態や読み込みスピナーなど)までの入力応答性を測定しますが、後続の非同期ペイント(fetch() の解決後にレンダリングされる新しいコンテンツや、クライアントサイドのルート遷移の完了など)は追跡しません。
  • シングルページ アプリ(SPA)でのルート変更をフレームワーク コードの外部でモニタリングすることは困難な場合があります。URL の更新をモニタリングするだけでは、ルートの変更に関連付けられていない URL の変更(アンカーリンクの変更、1 つのルート変更に対する複数の「リダイレクト」など)が過剰に報告される可能性があります。

InteractionContentfulPaint(interaction-contentful-paint)と PerformanceSoftNavigation(soft-navigation)のパフォーマンス エントリタイプは、このギャップを埋めるものです。非同期タスク(fetch、await、setTimeout)全体にインタラクションの因果関係を伝播し、新しいコンテンツの描画とシングルページ アプリケーション(SPA)のルート遷移を、それらを引き起こしたユーザー インタラクションに直接関連付けます。

導入方法

1. インタラクションによってトリガーされた Contentful Paint を観察する

type: 'interaction-contentful-paint' と buffered: true を指定した PerformanceObserver を使用して、ユーザー操作に起因する Largest Contentful Paint を段階的に観察します。

各 InteractionContentfulPaint エントリには次の情報が含まれます。

  • interactionId: 開始ユーザー インタラクションの固有識別子(PerformanceEventTiming.interactionId と PerformanceSoftNavigation.interactionId に一致)。
  • startTime: 開始ユーザー操作が開始されたときのタイムスタンプ。最初のハード ナビゲーションの時間の起点からの相対値です。
  • paintTime と presentationTime: レンダリング フェーズが終了したときと、ペイントされたピクセルが画面に描画されたとき。
  • duration: インタラクションの開始からペイントの表示までの経過時間(presentationTime - startTime)。
  • largestContentfulPaint: そのインタラクションでこれまでに描画された最大の要素に関する詳細(element、size、url、id、renderTime、loadTime)を含むスナップショット LargestContentfulPaint オブジェクト。
/**
 * 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. SPA ソフト ナビゲーションの Largest Contentful Paint(LCP)を測定する

ソフト ナビゲーションは、信頼できるユーザー操作によって、表示される URL の変更とコンテンツの描画の両方が発生したときに、ブラウザによって出力されます。各ソフト ナビゲーションの LCP を計算するには:

  1. buffered: true を使用して 'soft-navigation' エントリと 'interaction-contentful-paint' エントリの両方を監視します。
  2. 必須: 'soft-navigation' エントリを受信したら、softNavEntry.getLargestInteractionContentfulPaint() を呼び出して最初の LCP 候補をシードします。アプリケーションが URL を更新する前にコンテンツをペイントした場合(たとえば、ビューをレンダリングしてから history.pushState() を呼び出した場合)、それらの初期の interaction-contentful-paint エントリは soft-navigation エントリの前に出力され、以前の navigationId が保持されます。
  3. 必須: entry.interactionId === softNavEntry.interactionId を照合して、後続の 'interaction-contentful-paint' エントリをアクティブなソフト ナビゲーションに関連付けます。entry.navigationId で照合しないでください。interactionId を使用すると、URL の更新前に発生したペイントが含まれ、新しいルートでの後続の無関係なインタラクションによるペイントが除外されます。
/**
 * 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. web-vitals を使用してソフト ナビゲーション全体で Core Web Vitals を測定する

ハード ナビゲーションとソフト ナビゲーションの両方で標準の Core Web Vitals(LCP、CLS、INP)をアナリティクス エンドポイントにレポートすることが目的の場合は、すべての指標タイムラインを手動でスライスするのではなく、{ reportSoftNavs: true } で web-vitals ライブラリ(v6.0.0 以降)を使用します。

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

ベスト プラクティス

  • observer.observe() を呼び出す前に、PerformanceObserver.supportedEntryTypes?.includes(...) を使用して 'interaction-contentful-paint' と 'soft-navigation' の機能検出を行う。
  • 'interaction-contentful-paint' と 'soft-navigation' をモニタリングする場合は、{ buffered: true } を渡すようにします。これにより、アナリティクス コードが非同期で初期化される場合に、初期のエントリを見逃すことがなくなります。
  • 新しい soft-navigation エントリを処理するときに softNavEntry.getLargestInteractionContentfulPaint() を呼び出す。これにより、URL の更新前に発生したペイントが LCP に含まれるようになります。
  • navigationId ではなく interactionId を使用して、interaction-contentful-paint エントリをソフト ナビゲーションに一致させる。
  • 推奨: entry.presentationTime を直接報告するのではなく、entry.duration(または entry.presentationTime - entry.startTime)を使用してインタラクション ペイントのレイテンシを測定します。すべての PerformanceEntry タイムスタンプは、ソフト ナビゲーションの開始からではなく、最初のハード ページの読み込み時間の起点から測定されます。
  • performance.getEntriesByType('soft-navigation') を使用して、長期間にわたるシングルページ アプリケーションをモニタリングしないでください。ブラウザのバッファは、最初の 50 個のソフト ナビゲーション エントリに制限されているためです。

代替戦略

Baseline ターゲットに InteractionContentfulPaint または PerformanceSoftNavigation をサポートしていないブラウザが含まれている場合は、これらの API を Real User Monitoring(RUM)のプログレッシブ エンハンスメントとして扱います。

  1. 機能検出とグレースフル デグラデーション: PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint') と PerformanceObserver.supportedEntryTypes?.includes('soft-navigation') を使用してオブザーバーの登録を保護します。サポートされていないブラウザでは、オブザーバーの登録をスキップして、アプリケーションがエラーをスローせずに実行されるようにします。
  2. 標準のハード ナビゲーション オブザーバーを有効にしておく: すべてのブラウザで標準の 'largest-contentful-paint'、'event'、'layout-shift' エントリのモニタリングを継続します。LargestContentfulPaint は、ソフト ナビゲーションのサポートに関係なく、最初のユーザー操作で確定されるため、対応ブラウザでソフト ナビゲーションを測定しても、最初のハードロード LCP 測定が変更されたり、破損したりすることはありません。