web-vitals v5 は LCP・INP・CLS・FCP・TTFB を onLCP/onINP などの関数で購読でき、Attribution ビルドを使えば「どの要素が原因か」まで取得可能です。
ページアンロード時のデータ送信は fetch ではなく navigator.sendBeacon() か fetch(..., {keepalive: true}) を使います。visibilitychange+pagehideの両方で発火させるのが2026年時点の標準です。
集計は「平均」ではなくp75(Googleランキング指標) とp95(改善余地の可視化) を、モバイル/デスクトップ、地域、接続タイプ別に分けて見ます。
CrUX(Chrome UX Report)は原点値、RUMは自分のセグメントごとの深掘り。両者は代替ではなく補完 関係です。
SpeedCurve・DebugBear・Sentry・New Relic Browser・自前 BigQuery の5択には、それぞれ「向いているチーム規模」が明確に存在します。
目次
RUM(Real User Monitoring)とは何か
なぜSyntheticテストだけでは不十分なのか
web-vitals.js v5のセットアップとAttribution API
beaconエンドポイントの設計(sendBeacon・keepalive・edge)
p75・p95をデバイスクラス別に集計する
CrUXデータと自前RUMを突き合わせる
RUMプロバイダー比較:SpeedCurve、DebugBear、Sentry、自前
よくあるRUM実装のミスと対策
よくある質問
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 を引き起こしたイベントターゲット」「シフトが起きた要素」などが入るため、後述する原因追跡フェーズで必須になります。
Warning: onCLS は報告時点 のCLSではなくセッションウィンドウ内の最大値 を都度上書きで返します。同じmetric.idの更新イベントとして流れてくるので、サーバー側では「最終値だけ採用」または「id単位で最大値を取る」集計にしないと、CLSが二重計上されます。
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あたりだけで十分でしょう。
Tip: Cloudflare Analytics Engine を使えば、bindingしたWorkerから直接時系列DBに書き込めます。100万イベント/日なら月$5未満。GA4のCore Web Vitalsカスタムイベントは「サンプリング」が入るので、精度重視ならおすすめしません。
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由来」といった原因分析が可能になります。
Note: web-vitals v5 では INP Attribution に longAnimationFrameEntries(LoAF API)が含まれるようになりました。どのスクリプトのどの関数が長かったかまで、フィールドで追跡できます。Chrome 123+ が必要です。
よくある質問
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アドレスをそのまま保存するといった実装は避け、地理情報は国レベルまでに丸めてください。