最終更新: 2026年8月19日
RUM(Real User Monitoring)とは、実際のユーザーのブラウザから送られる性能データを収集・集計する計測手法で、web-vitals.js v5とnavigator.sendBeacon()を組み合わせれば、Core Web Vitals(LCP、INP、CLS、TTFB)を数十行のコードで自サイトのバックエンドに直接記録できます。正直に告白すると、私は過去にラボテストだけを信じてLCPを「2.1秒」だと社内に報告し、CrUXでp75=4.3秒と出て赤面した経験があります(会議室が一瞬静まり返ったあの空気は忘れられません)。以来、フィールドデータを一次情報として扱うことにしています。この記事では2026年時点の web-vitals API を前提に、beacon エンドポイントの設計、デバイスクラス別のp75/p95集計、そしてCrUXデータとの突き合わせまでを、実装コード込みで解説します。
RUM(Real User Monitoring)とは何か
RUMは、実ユーザーの端末で計測された性能指標を、匿名の集計データとしてサーバー側に送信・保管する手法です。web.dev のCore Web Vitals解説でも、フィールドデータ(field data)を「実際のユーザー体験を反映する唯一の情報源」と位置づけており、Google 検索ランキングに用いられるのもラボスコア(Lighthouse)ではなく、CrUXから抽出された28日ローリングのp75値です。
RUMが対象にする代表的な指標は次の5つです。
- LCP(Largest Contentful Paint):ビューポート内で最大の要素が描画されるまでの時間。
- INP(Interaction to Next Paint):2024年3月にFIDから置き換わったCore Web Vital。ページの寿命中で最も遅い応答(正確には95パーセンタイル付近)を代表値として採用します。
- CLS(Cumulative Layout Shift):セッションウィンドウ単位で最大シフトを取る指標。
- TTFB(Time to First Byte):Navigation Timingベース。CDNやSSRの効果を見るのに必須。
- FCP(First Contentful Paint):参考指標。LCPと組み合わせて「白紙時間」と「描画時間」を分離できます。
これらを 本物の Chrome ユーザーのブラウザ から集めるのがRUMの本質です。手元の MacBook で Chrome DevTools を回したときの数字ではなく、地下鉄で 3G 接続の Android Chrome ユーザーが体感した数字を集められる、というのが唯一無二の価値になります。
なぜSyntheticテストだけでは不十分なのか
Lighthouse や WebPageTest といったシンセティック(合成)テストは、統制された環境で毎回同じ条件を再現できる強みがあります。しかし実運用では次の要因が数字を大きくずらします。
- デバイス性能の分布:あなたの手元の M2 MacBook と、日本の高校生が使う Snapdragon 4 Gen 1 端末では、JavaScript の実行速度に10倍以上の差があります。INP はほぼ CPU 律速なので、この差がそのまま指標に出ます。
- ネットワークコンディション:CrUXの2026年Q2データでは、モバイルの4G未満(3G・2G・slow-2G)が全世界セッションの約12%を占めています。日本国内でもエレベーター内や地下街での「実効2G」は無視できません。
- キャッシュヒット率:Lighthouseは常にコールドロード。実ユーザーは同じ日に何度も同じサイトを訪れます。
- ユーザー操作パターン:INPはユーザーがクリック・タップ・キー入力した数だけサンプルが増えます。合成テストでは3〜5クリック程度しか再現しませんが、実ユーザーは1セッションで100回操作することもあります。
私自身、あるECサイトでLighthouseスコア98点を叩き出しながらCrUXでは「Needs Improvement」だった案件を担当したことがあります。原因は東南アジアの低スペック端末で商品カルーセルの JavaScript が800msブロックしていたことでしたが、東京のデータセンターで回したラボテストでは絶対に見つからない類の問題です。INP最適化の実践ガイドでも触れたように、INPは「ラボでは軽く、実機では重い」の代表格なので、RUMなしで最適化を語るのは、正直、ちょっと危険だと思います。
web-vitals.js v5のセットアップとAttribution API
Google Chrome チームが公式に配布している web-vitals ライブラリは、2025年に v5 系がリリースされ、Attribution API がすべての指標で安定化しました。gzip後で約4KBと軽量です。
インストールと最小構成
npm install web-vitals@^5次に、エントリポイントに以下のコードを追加します。絶対にクリティカルパスの JS ではなく、後読みのモジュールに置いてください(ここは何度も痛い目を見ました)。
// src/rum.ts
import { onLCP, onINP, onCLS, onTTFB, onFCP, type Metric } from 'web-vitals/attribution';
const ENDPOINT = '/api/rum';
function report(metric: Metric): void {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
attribution: metric.attribution, // v5でLCP/INP/CLS/TTFBすべてに実装
url: location.pathname, // クエリは意図的に落とす(PII混入防止)
deviceMemory: (navigator as any).deviceMemory ?? null,
hardwareConcurrency: navigator.hardwareConcurrency ?? null,
connection: (navigator as any).connection?.effectiveType ?? null,
saveData: (navigator as any).connection?.saveData ?? false,
ts: Date.now(),
});
// sendBeaconはunload時でも捨てられない。ただし64KB制限あり
if (!navigator.sendBeacon(ENDPOINT, body)) {
// フォールバック:keepaliveでfetch
fetch(ENDPOINT, { body, method: 'POST', keepalive: true });
}
}
onLCP(report);
onINP(report);
onCLS(report);
onTTFB(report);
onFCP(report);ポイントは web-vitals/attribution からインポートすることです。通常の web-vitals よりも約2KB大きくなりますが、Attribution オブジェクトに「LCP候補となった要素セレクタ」「INP を引き起こしたイベントターゲット」「シフトが起きた要素」などが入るため、後述する原因追跡フェーズで必須になります。
beaconエンドポイントの設計(sendBeacon・keepalive・edge)
受信側は「軽く、落ちにくく、地理的に近い」の3拍子が揃っている必要があります。私は次の三段構えを推奨しています。
1. エッジで受ける
ビーコンは1PVあたり最大5リクエスト(LCP、INP、CLS、TTFB、FCP)×数十バイトなので、Cloudflare Workers、Vercel Edge Functions、Netlify Edgeなどのエッジランタイムで受けきるのが最適です。オリジンに戻すのはコストの無駄です。
// app/api/rum/route.ts (Next.js 15 + Edge Runtime)
export const runtime = 'edge';
export async function POST(req: Request): Promise<Response> {
const raw = await req.text();
// 6KB以上は不正とみなして破棄(sendBeaconの64KB上限より厳しめ)
if (raw.length > 6000) return new Response(null, { status: 413 });
let payload: any;
try { payload = JSON.parse(raw); }
catch { return new Response(null, { status: 400 }); }
const row = {
...payload,
country: req.headers.get('cf-ipcountry') ?? null,
ua: req.headers.get('user-agent') ?? null,
received_at: new Date().toISOString(),
};
// R2にJSONL追記 or BigQueryにストリーミング挿入
await fetch(process.env.RUM_SINK_URL!, {
method: 'POST',
body: JSON.stringify(row),
headers: { 'Content-Type': 'application/json' },
});
// 204 No Content:ビーコンにレスポンス本文は不要
return new Response(null, { status: 204 });
}2. バッファリングは絶対にしない
sendBeaconはpagehideやvisibilitychange:hiddenのタイミングで発火する前提の API です。クライアントで「10件たまってから送る」実装にすると、モバイルSafariでバックグラウンド化されたときに全滅します。1メトリック=1リクエストを守ってください。
3. ペイロードは平坦なJSON
attribution オブジェクトはネストが深いので、サーバー側で JSON カラム(BigQueryならJSON型、Postgresならjsonb)に突っ込むのが後の分析で楽です。カラム化するのは name、value、rating、country、connection、deviceMemoryあたりだけで十分でしょう。
p75・p95をデバイスクラス別に集計する
集計クエリで最もやってはいけないのが AVG(value) です。LCPやINPは対数正規分布に近い形をしており、平均値は「重い側」に大きく引きずられる、あるいは逆に「大量の高速セッション」で薄まって実態が見えなくなります。使うのは必ずパーセンタイルです。
基本の p75 集計(BigQuery)
SELECT
DATE(received_at) AS day,
name,
CASE
WHEN connection IN ('4g') AND deviceMemory >= 4 THEN 'high-end-mobile'
WHEN connection IN ('4g') AND deviceMemory < 4 THEN 'mid-mobile'
WHEN connection IN ('3g','slow-2g','2g') THEN 'low-mobile'
ELSE 'desktop-or-unknown'
END AS device_class,
APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75,
APPROX_QUANTILES(value, 100)[OFFSET(95)] AS p95,
COUNTIF(rating = 'good') / COUNT(*) AS good_ratio,
COUNT(*) AS samples
FROM `myproj.rum.events`
WHERE received_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 28 DAY)
AND name IN ('LCP', 'INP', 'CLS', 'TTFB')
GROUP BY day, name, device_class
HAVING samples >= 100 -- 少数サンプルの外れ値を除外
ORDER BY day DESC, name, device_class;デバイスクラスで切る意味
CrUXは「PHONE」と「DESKTOP」の2クラスしか公開しません。しかし自前RUMなら、navigator.deviceMemoryとnavigator.hardwareConcurrency、navigator.connection.effectiveTypeを組み合わせて、次のような細かい層を作れます。
- ハイエンドモバイル(4GB RAM以上、4G)
- ミッドレンジモバイル(4GB RAM未満、4G)
- ローエンドモバイル(3G以下 or SaveData有効)
- デスクトップ
私が担当したメディアサイトでは、この分割だけで「ローエンドモバイルのINPが全体平均の3倍」という事実が可視化され、優先度の判断が一晩で変わりました。CLS完全攻略ガイドでも紹介した通り、CLSは特にモバイルで悪化しやすいため、デバイスクラス別のp75/p95トラッキングは必須です。
CrUXデータと自前RUMを突き合わせる
「自分のRUMがp75=2.4秒と言っているのに、CrUXは3.1秒と言っている。どっちが正しいの?」。これはRUM運用でよく発生する質問です。答えは「どちらも正しい、ただし観測している母集団が違う」。
| 比較軸 | CrUX | 自前RUM |
|---|---|---|
| 対象ブラウザ | Chromeのみ(同意ユーザー) | スクリプトが動く全ブラウザ |
| 粒度 | Origin単位・URL単位(Top URL) | ページ単位・セグメント単位で自由 |
| 更新頻度 | 28日ローリング・月次データセット | 秒単位(クエリ次第) |
| デバイスクラス | PHONE / DESKTOP / TABLET | 任意(memory、connection、地域など) |
| Google検索ランキング反映 | する(p75) | しない(自分の観測のみ) |
| コスト | 無料(BigQuery公開データセット) | 実装+インフラコスト |
CrUXをBigQueryで叩く
CrUX BigQuery公開データセットから自サイトの月次履歴を取り出せます。
SELECT
yyyymm,
device,
bin_LCP.p75 AS lcp_p75,
bin_INP.p75 AS inp_p75,
bin_CLS.p75 AS cls_p75
FROM `chrome-ux-report.materialized.metrics_summary`
WHERE origin = 'https://example.com'
AND yyyymm >= 202601
ORDER BY yyyymm DESC, device;これを自前RUMのp75と並べて Looker Studio でグラフ化すれば、乖離のある月=データ品質を疑うべき月がすぐわかります。私の運用ルールは「乖離が20%を超えたら実装を疑う、10%以内なら統計ノイズと割り切る」というものです。
RUMプロバイダー比較:SpeedCurve、DebugBear、Sentry、自前
自前実装が理想ですが、チーム規模やデータ基盤次第では商用プロバイダーが圧倒的に費用対効果が高いこともあります。2026年時点の代表的な選択肢を比較します。
| プロバイダ | 強み | 弱み | 向くチーム |
|---|---|---|---|
| SpeedCurve LUX | 指標の可視化UIが最強クラス、Custom Timings豊富 | 月額$$$、日本語UIなし | エンプラ・EC |
| DebugBear RUM | Attributionの表示が丁寧、価格が中庸 | データ保管期間が短めのプランがある | SaaS・中小規模 |
| Sentry Performance | エラー監視と統合、既に使っているなら追加コスト小 | CWV特化ではないためドリルダウンは浅め | スタートアップ |
| New Relic Browser | APMと統合、フルスタック観測 | UI/UXが古い、価格モデル複雑 | 大企業APMユーザー |
| Datadog RUM | ログ・APM・RUM統合、セッションリプレイ | コスト青天井、GDPRチューニング要 | Datadog依存企業 |
| 自前(web-vitals + BigQuery) | 完全な柔軟性、コスト最小 | ダッシュボード構築工数、運用責任 | 基盤チーム |
個人的な意見を述べると、月間PVが100万未満のスタートアップは Sentry で始めて、1000万を超えたら自前 BigQuery+Looker Studio に移行するのが最もコスト効率が良いと考えています。TTFB削減とJSバンドル最適化ガイドで扱ったようなインフラ最適化と RUM は表裏一体なので、両方を同じダッシュボードで見られるようにしておくべきです。
よくあるRUM実装のミスと対策
1. bfcache 復帰を無視している
Safari と Chrome の Back/Forward Cache(bfcache)復帰時は onLCP が発火しません。代わりに pageshow イベントで event.persisted === true のときに独自のリセットが必要です。web-vitals v5 は自動で処理しますが、独自実装している場合は要注意。
2. サンプリングを間違えている
「10%サンプリング」を Math.random() < 0.1 でやると、指標間で母集団が食い違います。LCPだけ入ってCLSが入らないセッションが増え、CrUXとの突合が壊れます。セッション単位でサンプリングし、そのセッション内は全指標を送るのが正解です。
// セッション単位サンプリング
const SESSION_KEY = '__rum_sampled__';
let sampled = sessionStorage.getItem(SESSION_KEY);
if (sampled === null) {
sampled = Math.random() < 0.1 ? '1' : '0';
sessionStorage.setItem(SESSION_KEY, sampled);
}
if (sampled === '1') {
onLCP(report); onINP(report); onCLS(report); onTTFB(report); onFCP(report);
}3. INPを最初のイベントで報告して終わっている
INPはページの寿命全体を通じた最悪値(正確にはユーザーの操作回数に応じた高いパーセンタイル)を出力する指標です。onINPにオプションを渡さない場合、visibilitychange時にしか報告しません。ダッシュボードで「INPが空欄」の場合はこれが原因のケースが多いです。
// リアルタイム性重視なら reportAllChanges: true
onINP(report, { reportAllChanges: true });4. PII混入
location.hrefやdocument.referrerにはクエリ文字列や個人情報が含まれることがあります。RUMのペイロードにはlocation.pathnameのみを入れ、必要ならさらにサーバー側でIDっぽい部分を/user/{id}にマスクしてください。GDPR的にも、Cookie同意なしで送れるのは「非個人識別のパフォーマンスメトリクス」のみです。
5. Attribution を保存していない
数字だけ集めても「なぜ遅いか」は見えません。LCP なら attribution.element(CSSセレクタ)、attribution.url(画像URL)、attribution.timeToFirstByte/resourceLoadDelay/elementRenderDelayの各サブメトリクスを必ず保存。これらがあれば「LCP候補要素の80%は<img class="hero">で、そのうち65%がelementRenderDelay由来」といった原因分析が可能になります。
よくある質問
RUMとSyntheticテストはどちらを優先すべきですか?
順位付けの判断(Google検索ランキング、KGI/KPI)にはRUMを、リグレッション検知にはSyntheticを使うのが正解です。RUMは「実ユーザーの体験=現実」を反映し、Syntheticは「同一条件での再現性」に強みがあります。両方を持って初めて、原因の特定と改善効果の測定が回ります。
web-vitals.jsを入れるとサイトが遅くなりませんか?
gzip後で約4KB、実行時間もマイクロ秒オーダーなので実測LCPには影響しません。ただしクリティカルパスに置くと初回JSパースが増えるため、必ず<script type="module">で遅延読込、あるいはメインバンドルにimport()で動的読込するのが安全です。
CrUXにデータが表示されないサイトでもRUMは有効ですか?
むしろ有効です。CrUXはトラフィックが一定以上ないと公開データセットに載りません。ロングテールのページや新規リリースサイトでは、自前RUMだけが唯一のフィールドデータになります。最低でもURL単位で月間数百セッションあれば意味のあるp75が計算できます。
p75とp95のどちらを見るべきですか?
Googleの合格ラインを追うならp75(ランキング指標がp75ベース)。しかし「まだ改善余地がある層」を可視化するにはp95を必ず併記します。私はp75で「合格ラインに乗ったか」、p95で「最悪ケースをどこまで削れたか」を見る、と使い分けています。
Cookie同意なしでRUMデータを送っても大丈夫ですか?
個人を識別できないパフォーマンス指標のみ(LCPの値、デバイスタイプ、接続タイプなど)であれば、多くの司法管轄区で「Strictly Necessary」または「正当な利益」に該当します。ただしセッションIDを長期Cookieに保存する、IPアドレスをそのまま保存するといった実装は避け、地理情報は国レベルまでに丸めてください。




