RUM実装ガイド 2026年版|web-vitals.jsでCore Web VitalsをCrUXと突き合わせる方法

web-vitals.js v5でCore Web VitalsをRUM計測する完全ガイド。sendBeacon・keepalive・Edge Runtimeでのbeaconエンドポイント設計、BigQueryでのp75/p95集計、CrUXデータとの突き合わせまで、2026年時点の実装コードと運用ノウハウを解説します。

最終更新: 2026年8月19日

RUM(Real User Monitoring)とは、実際のユーザーのブラウザから送られる性能データを収集・集計する計測手法で、web-vitals.js v5navigator.sendBeacon()を組み合わせれば、Core Web Vitals(LCP、INP、CLS、TTFB)を数十行のコードで自サイトのバックエンドに直接記録できます。正直に告白すると、私は過去にラボテストだけを信じてLCPを「2.1秒」だと社内に報告し、CrUXでp75=4.3秒と出て赤面した経験があります(会議室が一瞬静まり返ったあの空気は忘れられません)。以来、フィールドデータを一次情報として扱うことにしています。この記事では2026年時点の web-vitals API を前提に、beacon エンドポイントの設計、デバイスクラス別のp75/p95集計、そしてCrUXデータとの突き合わせまでを、実装コード込みで解説します。

  • web-vitals v5 は LCP・INP・CLS・FCP・TTFB を onLCPonINP などの関数で購読でき、Attribution ビルドを使えば「どの要素が原因か」まで取得可能です。
  • ページアンロード時のデータ送信は fetch ではなく navigator.sendBeacon()fetch(..., {keepalive: true}) を使います。visibilitychangepagehideの両方で発火させるのが2026年時点の標準です。
  • 集計は「平均」ではなくp75(Googleランキング指標)p95(改善余地の可視化)を、モバイル/デスクトップ、地域、接続タイプ別に分けて見ます。
  • CrUX(Chrome UX Report)は原点値、RUMは自分のセグメントごとの深掘り。両者は代替ではなく補完関係です。
  • SpeedCurve・DebugBear・Sentry・New Relic Browser・自前 BigQuery の5択には、それぞれ「向いているチーム規模」が明確に存在します。

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. バッファリングは絶対にしない

sendBeaconpagehidevisibilitychange:hiddenのタイミングで発火する前提の API です。クライアントで「10件たまってから送る」実装にすると、モバイルSafariでバックグラウンド化されたときに全滅します。1メトリック=1リクエストを守ってください。

3. ペイロードは平坦なJSON

attribution オブジェクトはネストが深いので、サーバー側で JSON カラム(BigQueryならJSON型、Postgresならjsonb)に突っ込むのが後の分析で楽です。カラム化するのは namevalueratingcountryconnectiondeviceMemoryあたりだけで十分でしょう。

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.deviceMemorynavigator.hardwareConcurrencynavigator.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 RUMAttributionの表示が丁寧、価格が中庸データ保管期間が短めのプランがあるSaaS・中小規模
Sentry Performanceエラー監視と統合、既に使っているなら追加コスト小CWV特化ではないためドリルダウンは浅めスタートアップ
New Relic BrowserAPMと統合、フルスタック観測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.hrefdocument.referrerにはクエリ文字列や個人情報が含まれることがあります。RUMのペイロードにはlocation.pathnameのみを入れ、必要ならさらにサーバー側でIDっぽい部分を/user/{id}にマスクしてください。GDPR的にも、Cookie同意なしで送れるのは「非個人識別のパフォーマンスメトリクス」のみです。

5. Attribution を保存していない

数字だけ集めても「なぜ遅いか」は見えません。LCP なら attribution.element(CSSセレクタ)、attribution.url(画像URL)、attribution.timeToFirstByteresourceLoadDelayelementRenderDelayの各サブメトリクスを必ず保存。これらがあれば「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アドレスをそのまま保存するといった実装は避け、地理情報は国レベルまでに丸めてください。

Nadia El-Sayed
著者について Nadia El-Sayed

Core Web Vitals specialist focused on real-user monitoring. Believes synthetic-only perf testing is a comforting lie.