📝 注意
本記事はAIの補助を受けて編集しています。
📚 目次
- 0. はじめに:徹底的に最適化したのに、ユーザーが実際どう感じているか分からない
- 1. RUMとは?ラボデータだけでは不十分な理由
- 2. RUMで追跡すべき指標
- 3. web-vitalsライブラリによる基本的なRUM実装
- 4. PerformanceObserverを使った高度なRUM実装 (TypeScript)
- 5. RUMデータの分析 – 数字の羅列から実行可能な洞察へ
- 6. RUMによるアラートとパフォーマンスバジェット
- 7. RUM導入時のセキュリティとプライバシー
- 8. 代表的なRUMツール比較
- 9. まとめと次回予告
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-id や request-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つのレベルで設定します:
- ビルド時 (CI): Lighthouse CI, バンドルサイズチェック
- 実行時 (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] フロントエンドパフォーマンス改善 完全ロードマップ総まとめ
