6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Frontend Performance - Part 19] ユーザーが感じる遅さを可視化するWebパフォーマンス計測(RUM)

6
Posted at

ChatGPT Image May 18, 2026, 03_58_23 PM.png

📝 注意
本記事はAIの補助を受けて編集しています。


📚 目次


0. はじめに:徹底的に最適化したのに、ユーザーが実際どう感じているか分からない

こんな経験はありませんか?

  • バンドルサイズの削減、コード分割、キャッシュ、CDNなどに何週間もかけたのに、実際にユーザーが速くなったと感じているかどうかを確認する手段がない
  • 新しいバージョンをデプロイしたら確実に良くなったはずなのに、ユーザーから「遅くなった」とクレームが来て、しかも再現できない
  • Lighthouseのスコアは100点なのに、「ブラジルのユーザーが3G回線で古いスマホを使ったらどうなるのか?」という問いに答えられない

もしこれらの質問に答えられないなら、問題はあなたの最適化スキルではありません。問題は:

適切な観測手段なしに最適化を続けている状態 です。

Part 19 では次の問いに答えます:

「Real User Monitoring (RUM) を構築し、実ユーザーのパフォーマンスデータを収集・分析・活用することで、『なんとなく速くなった気がする』という勘に頼った改善から脱却する方法」


1. RUMとは?ラボデータだけでは不十分な理由

Real User Monitoring (RUM) は、実際のユーザーがアプリケーションを閲覧・操作した際のパフォーマンスデータを収集する技術です。LighthouseやWebPageTestなどのラボデータは管理された環境で測定されますが、RUMは多様なデバイス、ブラウザ、ネットワーク、世界中のユーザー環境におけるリアルなユーザー体験を反映します。

1.1. ラボデータ vs フィールドデータ

特徴 ラボデータ (Lighthouse) フィールドデータ (RUM)
環境 シミュレーション(制御下) 実際のユーザー環境
デバイス 限定的・固定 多様(iPhone, Android, 旧PC...)
ネットワーク スロットリング模擬 3G, 4G, 5G, 公共Wi-Fi...
地理的条件 少数の地域 世界中のユーザー
代表性 理想的 現実的

GoogleはSEOランキングにフィールドデータ(CrUX)を使用しており、Lighthouseのスコアは直接的なランキング要因ではありません。

1.2. なぜラボデータだけでは不十分なのか?

実際のユーザーは以下のような多様な環境でアクセスします:

  • モバイルネットワーク(3G/4G/5G、地域によって変動)
  • 低スペック端末(RAM 2GB, CPU弱)
  • 古いブラウザ(旧Safari、Samsung Internet)
  • リソースを消費する拡張機能やセキュリティソフト

ラボデータではこれらの変数をすべて再現できません。RUMは「理想的なパフォーマンス」ではなく、実際のユーザー体験 を可視化します。

1.3. RUM vs シンセティックモニタリング

手法 シンセティックモニタリング RUM
方法 定期的にボットがスクリプト実行 実ユーザーから収集
メリット ユーザーが気づく前に障害検知、ベースライン安定 実際の体験を反映、多様な条件を捉える
デメリット 実ユーザーを代表しない テスト条件を制御できない

両方を補完的に使うのがベスト:シンセティックで早期リグレッションを検出し、RUMで実ユーザーへの影響を確認します。

補足:広告ブロッカー、CSP (Content Security Policy) 制約、企業ファイアウォール、プライバシーブラウザによってRUMのビーコンがブロックされる場合があります。これは避けられないデータ損失として許容しましょう。


2. RUMで追跡すべき指標

RUMはCore Web Vitalsだけではありません。包括的なRUM戦略には以下を含めます。

2.1. Core Web Vitals(必須)

指標 意味 Goodのしきい値
LCP メインコンテンツ表示速度 < 2.5秒
INP インタラクション遅延(2024年よりFIDを代替) < 200ms
CLS レイアウトシフト量 < 0.1

これらの指標は75パーセンタイル (p75) で評価するのがGoogleの推奨です。平均値は外れ値に大きく影響されるため不適切です。

2.2. 追加すべき技術指標

指標 意味 用途
TTFB サーバー応答時間 バックエンド/ネットワーク問題のデバッグ
FCP 最初のコンテンツ表示時間 LCPの補足
JavaScriptエラー率 セッションあたりのJSエラー割合 UXに影響するエラー検出
API呼び出し時間 フロントエンドからのAPI遅延 バックエンドボトルネック特定
ロングタスク >50msのメインスレッドブロック INP/TBTのデバッグ

2.3. ビジネス指標との連携 – パフォーマンスとビジネスの橋渡し

RUMの真価は、パフォーマンスをビジネス成果に結びつける点にあります。

ビジネス上の問い RUMでどう答えるか
遅いページが原因でカゴ落ちしたユーザーは何人か? チェックアウト失敗 + LCP/INP高いセッションを抽出
新機能導入で既存ユーザーは遅くなったか? デプロイ前後のセグメント比較
どの地域のユーザーが最も影響を受けているか? 地域・デバイス・回線種別でセグメント

例えば、「エラー率が3%上昇した」というダッシュボードはよくありますが、RUMは「カートに3回クリックしても反応がなく、最終的に高額顧客が離脱した」という文脈付き情報を提供できます。これが単なる数値の収集ではなく、改善アクションにつながる分析の重要性です。


3. web-vitalsライブラリによる基本的なRUM実装

web-vitals はGoogle公式の軽量ライブラリで、LCP、INP、CLSの収集ベストプラクティスを提供します。

3.1. インストール

npm install web-vitals

3.2. 基本実装 (TypeScript) – セッションベースのサンプリング付き

// utils/rum.ts
import { onLCP, onINP, onCLS, Metric } from 'web-vitals';

// サンプリングレート: コスト削減のため10%のユーザーのみ追跡
const SAMPLE_RATE = 0.1; // 10%

// ✅ 重要: メトリクスごとではなくセッション単位でサンプリング
const SHOULD_TRACK_SESSION = Math.random() < SAMPLE_RATE;

セッション単位サンプリングを推奨
メトリクスごとに Math.random() を実行すると、
LCPだけ送信されてINP/CLSが欠落するなど、
同一ユーザーセッションの整合性が崩れる可能性があります。

必ず セッション単位 でサンプリングし、
同一ユーザーの主要メトリクスをまとめて分析できるようにしましょう。

サンプリング率と偏りの注意

サンプリング率が極端に低い場合(例: 1%未満)、
特定の地域・デバイス・ブラウザのデータが不足し、
分析結果に偏りが発生する可能性があります。

セグメント分析を重視する場合は、必要に応じてサンプリング率の調整を検討してください。

function getDeviceType(): string {
  // ⚠️ `navigator.userAgent` は将来的に縮小される可能性があります。
  // Chrome系ブラウザでは `navigator.userAgentData` の利用も検討してください。
  const ua = navigator.userAgent;
  if (/(tablet|ipad|playbook|silk)|(android(?!.*mobile))/i.test(ua)) return 'tablet';
  if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated/i.test(ua)) return 'mobile';
  return 'desktop';
}

// オフライン時やビーコン失敗時にメトリクスをキューイング
let pendingMetrics: any[] = [];
function flushPendingMetrics() {
  if (pendingMetrics.length === 0) return;
  const body = JSON.stringify(pendingMetrics);
  const blob = new Blob([body], { type: 'application/json' });
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/rum', blob);
    pendingMetrics = [];
  } else {
    fetch('/api/rum', {
      method: 'POST',
      body,
      headers: { 'Content-Type': 'application/json' },
      keepalive: true,
    }).then(() => { pendingMetrics = []; });
  }
}

function sendToAnalytics(metric: Metric) {
  if (!SHOULD_TRACK_SESSION) return;

  // URL正規化: カーディナリティ爆発を防ぐ
  const normalizedPath = location.pathname.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, '/:uuid')
    .replace(/\/\d+/g, '/:id');

  const body = {
    name: metric.name,
    value: metric.value,
    delta: metric.delta,
    id: metric.id,
    navigationType: performance?.getEntriesByType?.('navigation')[0]?.type,
    url: normalizedPath,
    deviceType: getDeviceType(),
    // UA Client Hints (navigator.userAgentData) が将来の標準
    platform: (navigator as any).userAgentData?.platform || navigator.platform,
    connection: (navigator as any).connection?.effectiveType ?? 'unknown',
    timestamp: Date.now(),
  };

  if (!navigator.onLine) {
    pendingMetrics.push(body);
    window.addEventListener('online', flushPendingMetrics, { once: true });
    return;
  }

  const blob = new Blob([JSON.stringify(body)], { type: 'application/json' });
  if (navigator.sendBeacon) {
    const sent = navigator.sendBeacon('/api/rum', blob);
    if (!sent) pendingMetrics.push(body);
  } else {
    fetch('/api/rum', {
      method: 'POST',
      body: JSON.stringify(body),
      headers: { 'Content-Type': 'application/json' },
      keepalive: true,
    }).catch(() => pendingMetrics.push(body));
  }
}

export function initRUM() {
  if (import.meta.env.PROD) {
    onLCP(sendToAnalytics);
    onINP(sendToAnalytics);
    onCLS(sendToAnalytics);
  }
}

sendBeaconは100%成功を保証しません

sendBeacon() はページ離脱時の送信に便利ですが、
ブラウザ制限・ネットワーク状態・広告ブロッカーによって
送信が失敗する場合があります。

そのため本記事のように:

  • offline queue
  • retry
  • fallback (fetch)

を組み合わせる設計が安全です。

3.3. Reactアプリへの組み込み

// main.tsx
import { initRUM } from './utils/rum';
initRUM();

3.4. Attributionビルド – INP/LCPの詳細デバッグ

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

onLCP((metric) => {
  console.log('LCP要素:', metric.attribution?.element);
  console.log('LCP読み込み時間:', metric.attribution?.loadTime);
  console.log('LCP URL:', metric.attribution?.url);
});

onINP((metric) => {
  console.log('INPインタラクション対象:', metric.attribution?.interactionTarget);
  console.log('INPイベント種別:', metric.attribution?.eventType);
  console.log('INP読み込み状態:', metric.attribution?.loadState);
});

INPのデバッグはLCPより難しいため、attribution情報が特に役立ちます。


4. PerformanceObserverを使った高度なRUM実装 (TypeScript)

web-vitalsにないメトリクス(TTFB, FCP, ロングタスク, SPA遷移)を収集したい場合は、PerformanceObserverを直接使います。observerを使い終わったら必ずdisconnectしてメモリリークを防ぎます。

NG: Observerを永続的に放置する

以下のような実装は避けましょう。

  • PerformanceObserver を mount 時に生成
  • route change 後も放置
  • cleanup しない

SPAでは observer が積み上がり、
重複計測やメモリリークの原因になります。

4.1. TTFBとFCPの収集(正しい方法)

// utils/advanced-rum.ts
function sendToBackend(data: any) { /* ... */ }

export function observePerformanceMetrics() {
  // LCP – ページ非表示時に切断
  const lcpObserver = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    sendToBackend({ name: 'LCP', value: lastEntry.startTime });
  });
  lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
  
  // ✅ visibilitychange + pagehide でクリーンアップ(beforeunloadより信頼性が高い)
  const cleanup = () => {
    lcpObserver.disconnect();
    fcpObserver.disconnect();
  };
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') cleanup();
  });
  window.addEventListener('pagehide', cleanup);

  // FCP – first-contentful-paintを正しくフィルタ
  const fcpObserver = new PerformanceObserver((list) => {
    const fcpEntry = list.getEntries().find(
      (entry) => entry.name === 'first-contentful-paint'
    );
    if (fcpEntry) {
      sendToBackend({ name: 'FCP', value: fcpEntry.startTime });
    }
  });
  fcpObserver.observe({ type: 'paint', buffered: true });

  // TTFB
  const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
  if (navEntry) {
    const ttfb = navEntry.responseStart - navEntry.requestStart;
    sendToBackend({ name: 'TTFB', value: ttfb });
  }
}

4.2. ロングタスクAPI – INP悪化の原因を特定

let longTaskObserver: PerformanceObserver | null = null;

export function observeLongTasks() {
  longTaskObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.duration > 50) {
        sendToBackend({
          name: 'LONG_TASK',
          value: entry.duration,
          metadata: {
            startTime: entry.startTime,
            // Safariなど attribution 非対応ブラウザのフォールバック
            attribution: 'attribution' in entry ? (entry as any).attribution : null
          },
        });
      }
    }
  });
  longTaskObserver.observe({ type: 'longtask', buffered: true });
}

export function disconnectLongTasks() {
  longTaskObserver?.disconnect();
}

4.3. リソースタイミング – 遅いリソースの検出(しきい値はリソース種別で調整)

let resourceObserver: PerformanceObserver | null = null;

export function observeResourceTiming() {
  resourceObserver = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    for (const entry of entries) {
      const duration = entry.duration;
      const transferSize = (entry as PerformanceResourceTiming).transferSize;
      // リソース種別ごとにしきい値を調整
      let threshold = 1000;
      if (entry.initiatorType === 'img') threshold = 500;
      else if (entry.initiatorType === 'script') threshold = 300;
      if (duration > threshold || transferSize > 500 * 1024) {
        sendToBackend({
          name: 'SLOW_RESOURCE',
          value: duration,
          metadata: {
            name: entry.name,
            initiatorType: entry.initiatorType,
            transferSize,
          },
        });
      }
    }
  });
  resourceObserver.observe({ type: 'resource', buffered: true });
}

export function disconnectResourceObserver() {
  resourceObserver?.disconnect();
}

4.4. SPAルート遷移メトリクス (React Router) – 正確な測定のために

ルート遷移時間を正確に測るには、ナビゲーションの前にマークします。

import { useNavigate } from 'react-router-dom';

function useNavigationTracking() {
  const navigate = useNavigate();
  const trackedNavigate = (to: string, options?: any) => {
    performance.mark('route-start');
    navigate(to, options);
  };
  return trackedNavigate;
}

遷移後に測定:

function RouteChangeTracker() {
  const location = useLocation();
  useEffect(() => {
    requestAnimationFrame(() => {
      performance.mark('route-end');
      performance.measure('route-transition', 'route-start', 'route-end');
      const measure = performance.getEntriesByName('route-transition')[0];
      if (measure) {
        sendToBackend({ name: 'ROUTE_TRANSITION', value: measure.duration, path: location.pathname });
      }
      // 自前のマークのみ削除
      performance.clearMarks('route-start');
      performance.clearMarks('route-end');
      performance.clearMeasures('route-transition');
    });
  }, [location]);
  return null;
}

React Suspense / lazy loading を利用している場合の注意

requestAnimationFrame() は実際の画面表示完了を正確に表さない場合があります。

route loader完了, data fetch完了, skeleton解除などの独自イベントと組み合わせる方が現実的です。

Next.js App Routerの注意点

App Routerでは従来の router.events が存在しないため、
Pages Router時代と同じ計測方法は使えません。

usePathname(), useSearchParams(), instrumentation.ts などを組み合わせて計測する必要があります。


5. RUMデータの分析 – 数字の羅列から実行可能な洞察へ

5.1. セグメンテーション – RUM分析の要

集計データ(全ユーザー)は問題を隠してしまいます。常に以下の軸でセグメントしましょう。

セグメント 理由
デバイス種別 モバイルとデスクトップではパフォーマンスが大きく異なる
ブラウザ SafariはChromeより特定指標で遅いことがある
回線種別 3Gユーザーは優先的に最適化すべき
地域 サーバーから遠いユーザーはTTFBが高い
アプリバージョン デプロイ直後のリグレッションを検出

例: 平均LCPが2.0秒で一見良さそうでも、Androidでセグメントすると3.5秒になるかもしれません。これが本当の洞察です。

5.2. 75パーセンタイル (p75) – Googleの採用する指標

GoogleはCore Web Vitalsを75パーセンタイルで報告します。これは「75%のユーザーがこのしきい値以上の体験をしている」という意味です。平均値は外れ値に大きく影響されるため不適切です。

5.3. RUMとバックエンドトレーシングの連携

RUMはフロントエンドから見える情報だけです。LCP/INP悪化の根本原因を特定するには以下と連携します:

  • バックエンドAPM (Datadog, New Relic, OpenTelemetry) – 遅いリクエストを特定
  • CDNログ – キャッシュヒット/ミスを確認
  • DBクエリログ – 遅いクエリを発見

ヒント: バックエンドからHTML/APIレスポンスに trace-idrequest-id を埋め込み、RUMペイロードに含めると、フロントエンドとバックエンドのデータを結合できます。

5.4. ダッシュボード例 – 何を含めるべきか

効果的なRUMダッシュボードには以下を含めます:

  • Core Web Vitalsの時系列トレンド (p75)
  • LCP/INPが最も遅いトップ5ページ
  • セグメント比較表(モバイル vs デスクトップ, ブラウザ, 地域)
  • JavaScriptエラー率の推移
  • 遅いAPI呼び出し (fetch/XHR時間)
  • ロングタスクの分布

6. RUMによるアラートとパフォーマンスバジェット

6.1. しきい値アラート – 早期リグレッション検出

アラート 条件 アクション
LCP悪化 LCP p75 > 2.5秒 が5分継続 直近のデプロイを確認、セグメント分析
INP急上昇 INP > 300ms ロングタスクやJS実行を調査
エラー率急増 JSエラー率 > 1% Sentryなどでエラートラッキング
特定地域で劣化 ある地域のLCP > 3.0秒 CDNやエッジサーバーを調査

6.2. パフォーマンスバジェット – デグレを未然に防ぐ

バジェットは2つのレベルで設定します:

  1. ビルド時 (CI): Lighthouse CI, バンドルサイズチェック
  2. 実行時 (RUM): デプロイ後にLCP p75が閾値を超えたら自動警告
// budget.json 
{
  "thresholds": {
    "lcp": { "good": 2500, "needsImprovement": 4000 },
    "inp": { "good": 200, "needsImprovement": 500 },
    "cls": { "good": 0.1, "needsImprovement": 0.25 }
  }
}

7. RUM導入時のセキュリティとプライバシー

RUMはブラウザからデータを収集するため、セキュリティとプライバシーは必須要件です。

7.1. GDPR / CCPA準拠

  • PIIを収集しない(メール、名前、住所、電話番号など)
  • ユーザーIDを匿名化: ハッシュ化または逆追跡不可能なIDを使用
  • Cookie同意: GDPR/CCPA対象地域ではユーザー同意後にRUMを初期化
  • データ保持期間: 定期的に古いデータを削除

NG: URLクエリをそのまま送信する

以下のようなURLをそのままRUMへ送るのは危険です。

/checkout?email=xxx@gmail.com

/reset-password?token=xxxxx

/invite?userId=123

必ずクエリパラメータの除去・匿名化を行いましょう。

7.2. セッションリプレイ – 慎重に

セッションリプレイ(ユーザー操作全体の録画)はデバッグに強力ですが、セキュリティリスクも高い:

  • センシティブフィールドをマスク: パスワード、クレジットカード、メールを自動隠蔽
  • 低いサンプリングレート: リスク低減のため1-5%程度に
  • 明示的なオプトイン: ユーザーの明確な同意が必要

7.3. ベストプラクティス

  • URLに機密情報(セッションID)を含めない。正規化(例: /product/123/product/:id)でカーディナリティ爆発も防止。
  • sendBeacon + Blobを使用し、ページアンロード時もデータ送信を保証。
  • サンプリングレートは10-20%程度に設定(洞察とコストのバランス)。
  • オフライン時や送信失敗時にメトリクスをキューイングし、オンライン復帰時に再送。

広告ブロッカーなどについて: 広告ブロッカー、CSP制約、企業ファイアウォール、プライバシーブラウザによってRUMビーコンがブロックされる場合があります。これは避けられないデータ損失として許容してください。


8. 代表的なRUMツール比較

ツール 強み 価格傾向 対象
Sentry Performance エラートラッキングとパフォーマンス統合 Freemium〜 エラー+パフォーマンス両方必要なチーム
Datadog RUM フルスタック可観測性、セッションリプレイ 従量制〜 Datadog既存ユーザー、大企業
New Relic Browser APM + RUM強力 従量制〜 New Relicバックエンド利用チーム
Vercel Speed Insights Next.jsに標準統合、簡単 Vercelプラン依存 Next.js開発者
Cloudflare RUM CDN統合、プライバシー重視 従量制〜 Cloudflare利用チーム
OpenTelemetry + Collector ベンダーロックインなし、カスタマイズ性高い 運用コスト 可観測性先進チーム
自作 (web-vitals + バックエンド) データを完全制御、低コスト 運用コスト 自前構築したいチーム

また、Google CrUX Dashboard は無料で地域別のCore Web Vitals概要を提供するため、自前RUM導入前の把握に役立ちます。

まずは自作 + 軽量構成でも十分

小規模〜中規模プロダクトでは、

  • web-vitals
  • BigQuery
  • ClickHouse
  • Grafana

などを組み合わせるだけでも十分なRUM基盤を構築できます。
最初から高額なSaaSを導入する必要はありません。

コスト注意: RUMは適切にサンプリングしないと高コストになりがちです。重要度の低いイベントはサンプリングし、予算が限られている場合はパフォーマンス指標よりエラー優先が良いでしょう。


9. まとめと次回予告

概念 内容
RUMとは 実ユーザーからパフォーマンスデータを収集、多様な条件を反映
ラボ vs フィールド ラボは可能性、フィールドは現実
主要指標 Core Web Vitals (p75), エラー率, API遅延, ロングタスク
web-vitalsライブラリ 軽量、LCP/INP/CLS収集のベストプラクティス
サンプリング コスト削減のためセッションベースで実施、偏りに注意
SPA遷移 個別に performance.mark で測定必要、Suspense/lazyに注意
Observerクリーンアップ メモリリーク防止のためdisconnect必須、visibilitychange/pagehide活用
バックエンド連携 trace-idでフロント・バックを結合し根本原因特定
セグメンテーション データを洞察に変える鍵
アラート&バジェット リグレッション早期検出、デグレ防止
プライバシー GDPR/CCPA準拠、PII収集禁止、URL正規化

👉 次回予告 (Part 20 – 最終回):
[Frontend Performance - Part 20] フロントエンドパフォーマンス改善 完全ロードマップ総まとめ

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?