メインコンテンツへスキップ
ブログ一覧に戻る
Web制作・運用

Webパフォーマンス測定とモニタリング完全ガイド:継続的な改善のための実践手法

2025年11月15日
最終更新: 2026年10月8日
10分で読めます

この記事でわかること

Webパフォーマンス監視を、ラボ計測と実利用者の分布に分けて設計します。LCP・INP・CLS、web-vitalsの更新、欠測、端末別p75を具体例とコードで解説。Lighthouse、RUM、通知、画像改善の役割を整理し、単位・期間・標本数をそろえた比較と、外部接続を行わない教材検証の限界まで確認できます。

2026年10月8日に、GoogleChromeのweb-vitals公式READMEを再読しました。ライブラリは実利用者のWeb Vitalsを測定する小さなモジュールとして説明され、現行のCore Web VitalsはLCP・INP・CLS、診断にFCP・TTFBなどを扱います。標準ビルドとattributionビルドがあり、API例はonLCP・onINP・onCLSです。これは計測ライブラリの現行仕様であり、実サイトのスコア、端末別p75、改善効果、合否判定を自動的に保証するものではありません。

30秒で要点

  • 現行のCore Web VitalsはLCP・INP・CLSです。ラボの再現実験と、実利用者から得るフィールドの分布を別々に記録します。
  • 集計は端末・対象ページ・期間・標本数をそろえます。INPが未観測のページを0msとして追加せず、CLSの小数も残します。
  • ツールの導入より先に、何を測り、誰がどの条件で変更するかを決めます。以下のAPIや通知の例は、外部送信を行わないローカル教材と運用接続部分を区別しています。

監視の単位を先に決める

Core Web Vitalsの公式説明では、良好の目安はLCP 2.5秒以下、INP 200ms以下、CLS 0.1以下です。フィールドでの評価はページ読み込みの75パーセンタイルを使い、モバイルとデスクトップを分けます。平均値が良好でも、遅い利用者群が隠れる場合があります。

ここでは架空の20件を、モバイル10件とデスクトップ10件に分けます。nearest-rank方式なら各群のp75は並べ替えた8番目、全20件なら15番目です。二群のp75を平均して全体のp75にすることはできません。実際のサービスが用いる集計定義はその仕様へ合わせ、教材の定義と区別してください。

架空のモバイル10件とデスクトップ10件のp75、全20件のp75を別に示す図
著者作成の架空データ。nearest-rank方式で各10件は8番目、全20件は15番目です。群のp75の平均とは一致しません。

表が収まらない場合は、横にスクロールできます

記録項目この教材の例混ぜないもの
対象商品詳細、同じリリース一覧や管理画面を無条件に合算
端末群モバイル、デスクトップ端末構成の変化を改善と断定
値と単位LCP/INPはms、CLSは単位なしCLSに1000を掛けて元の単位と偽る
標本指標ごとの有効観測数と欠測数INP未観測を0に補完
実験条件ラボの端末・回線・キャッシュラボ点数とフィールドp75の直接比較

1. ラボ計測で変更の仮説を確かめる

LighthouseのCLIとNode API

次はプロジェクトへバージョン固定済みのLighthouseがある場合の実行手順です。無断でグローバルへ追加する手順ではありません。URLは架空なので、実際には自分が計測できる対象へ置換します。ラボの条件と実行時刻を保存します。

npm exec --no -- lighthouse https://example.test/product \
  --output=json --output-path=./lighthouse-report.json \
  --only-categories=performance

Node APIを使う場合も起動したChromeの終了処理を保証します。これはLighthouse公式Node APIを根拠にした教材で、このページの検証で実Chromeを起動したり外部サイトを監査したりしたという意味ではありません。

import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';
import { writeFile } from 'node:fs/promises';
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
try {
  const run = await lighthouse('https://example.test/product', {
    port: chrome.port, output: 'json', onlyCategories: ['performance'],
  });
  if (!run) throw new Error('No Lighthouse result');
  await writeFile('./lighthouse-report.json', run.report, 'utf8');
} finally { await chrome.kill(); }

既定のナビゲーション監査だけでは、利用者のクリックや入力に伴うINPを代表できません。一方、DevTools等の操作を伴うラボ診断でINPを調べることまで不可能という意味ではありません。フィールドで遅い操作を特定し、その操作をラボで再現する関係です。

WebPageTestの非同期ジョブを扱う考え方

外部計測では、投入後に結果が完成するまで待つAPIが使われることがあります。WebPageTestの現行API本文は今回取得できなかったため、エンドポイント・認証・statusコードの具体仕様をここでは保証しません。次はサービス非依存のポーリング教材です。readStatusは実サービス仕様に合わせたアダプターを注入する場所で、WebPageTestの実SDKとしては実行していません。

export async function waitForReport(readStatus, sleep, attempts = 3) {
  if (!Number.isInteger(attempts) || attempts < 1) throw new Error('attempts');
  for (let n = 0; n < attempts; n++) {
    const result = await readStatus();
    if (result.state === 'complete') return result.report;
    if (result.state !== 'pending') throw new Error('measurement failed');
    if (n + 1 < attempts) await sleep(1000 * (n + 1));
  }
  throw new Error('measurement timeout');
}

最大3回なら待機は1秒と2秒の計3秒ですが、API自体の時間は別です。実接続時は各要求のタイムアウトや取消、レート制限、費用も設計します。失敗を空の成功レポートに変えないことが大切です。

PageSpeed Insightsのラボとフィールドを分ける

PageSpeed Insightsの開始ガイドでは、lighthouseResultとloadingExperienceが別の応答領域です。資料内の古い応答例にFIDがあっても、現在の評価指標へ戻す根拠にはしません。次は与えられたJSONを読む関数で、API要求は送りません。

export function parseInsights(data) {
  const score = data.lighthouseResult?.categories?.performance?.score;
  return {
    labPerformanceScore: typeof score === 'number' ? score : null,
    field: data.loadingExperience?.metrics ?? null,
    originField: data.originLoadingExperience?.metrics ?? null,
  };
}

URL単位のデータが足りないことと、オリジン全体の結果が悪いことは別です。nullを0点と表示しないよう、ダッシュボードにも「データなし」を用意します。

2. RUMを収集する前に送信と重複の方針を決める

web-vitalsの現行API

web-vitalsではonLCP、onINP、onCLSを使います。変更履歴のv3でget系からon系へ変わり、v4でFIDが非推奨、v5で削除されています。古い記事のgetFIDを現在のAPIとして追加し直す対応はしません。利用する版は自分のlockfileで確認します。

同じmetric IDについて値が更新される場合があります。次は収集側がid単位で最新値を上書きする契約の教材です。deltaの加算方式と同時に使って二重計上しません。値の丸めをせず、CLS 0.08を0へ落とさないようにします。

import { onLCP, onINP, onCLS } from 'web-vitals';
export function installVitals(send) {
  const latest = new Map();
  const report = ({ id, name, value }) => {
    if (!Number.isFinite(value)) return;
    if (latest.get(id) === value) return;
    latest.set(id, value);
    send({ id, name, value }); // 収集側もidごとの最新値として扱う
  };
  onLCP(report); onINP(report); onCLS(report);
}

実送信先は自社収集やGA4などから選べますが、URLの個人情報、同意、保存期間、許可された項目を別途設計してください。この関数のsendはローカル記録器に差し替えて検証できます。生のDOM文字列や入力値を無条件で送る実装にはしません。

フレームワークの入口と手動計測を重ねない

Pages RouterのreportWebVitalsを使う構成の教材です。App Routerへそのまま移すものではなく、ここからの収集と上の手動observerを両方有効にして二重送信しないよう選びます。以下はコンソール確認までで、外部送信ではありません。

// pages/_app.js で利用する構成の例
export function reportWebVitals(metric) {
  if (!['LCP', 'INP', 'CLS'].includes(metric.name)) return;
  console.log({ id: metric.id, name: metric.name, value: metric.value });
}

PerformanceNavigationTimingのstartTimeは0です。旧APIのnavigationStartがこのオブジェクトにあると仮定して引かないでください。loadがまだ終了していなければloadEventEndが0のままなので、完了後に読み直します。

export function navigationSummary(perf) {
  const nav = perf.getEntriesByType('navigation')[0];
  if (!nav || nav.loadEventEnd === 0) return null;
  return {
    responseStartMs: nav.responseStart - nav.startTime,
    domReadyMs: nav.domContentLoadedEventEnd - nav.startTime,
    loadMs: nav.loadEventEnd - nav.startTime,
  };
}

応答開始までの時間に何が含まれるか、リダイレクトやキャッシュ状態も合わせて読みます。より詳細な原因調査はTTFBの手順へ進めます。

業務操作の開始と終了を対にする

検索ボタンから結果表示までなど、業務上の区間は自分で定義できます。次は非同期処理を囲む教材です。呼出しごとに一意なprefixを渡し、終了markを実際にmeasureへ使います。失敗した操作は呼出側で別の結果区分に記録してください。

export async function measureOperation(perf, prefix, operation) {
  const start = prefix + ':start', end = prefix + ':end';
  perf.mark(start);
  try {
    const result = await operation();
    perf.mark(end); perf.measure(prefix, start, end);
    return result;
  } finally {
    perf.clearMarks(start); perf.clearMarks(end);
  }
}

返ってきたデータだけを終了点とするか、描画完了を終了点にするかで意味が変わります。後者には描画の観測を別途組み込みます。measureエントリーの回収・削除も、収集側の寿命に合わせて設計します。

3. ダッシュボードと通知を小さく接続する

古い要求が新しい表示を上書きしない

次は重複ポーリングを避け、停止後に結果が来ても描画しない教材です。fetchDataとrenderは注入し、チャートライブラリの導入や外部APIを前提にしません。LCP・INPのmsとCLSの無単位値を一つの縦軸に混ぜず、描画側で別パネルにします。

export function startDashboard(fetchData, render, schedule, cancel) {
  let stopped = false, handle = null;
  const tick = async () => {
    try {
      const data = await fetchData();
      if (!stopped) render({ state: 'ready', data });
    } catch {
      if (!stopped) render({ state: 'error' });
    } finally {
      if (!stopped) handle = schedule(tick, 60000);
    }
  };
  void tick();
  return () => { stopped = true; if (handle !== null) cancel(handle); };
}

描画を止めても、進行中の通信自体は止まりません。実際にはAbortController等を接続し、非表示中の運用、再表示時の鮮度、エラー時の表示も決めます。

閾値通知を改善の証拠にしない

次の通知関数は、同じ期間・群のp75と有効観測数を受け取る想定です。20件という下限は教材の運用方針で、統計的な信頼性を保証する標本数ではありません。外部Slack URLや資格情報をブラウザへ埋め込まず、通知先はサーバー側等の許可された経路に接続します。

export function evaluateAlert(summary, notify) {
  if (!Number.isInteger(summary.validCount) || summary.validCount < 20 ||
      !Number.isFinite(summary.inpP75Ms)) return 'insufficient';
  if (summary.inpP75Ms <= 200) return 'within-target';
  notify({ kind: 'inp-review', segment: summary.segment,
    valueMs: summary.inpP75Ms, validCount: summary.validCount });
  return 'review-needed';
}

毎分同じアラートを出す場合は、期間・群・指標のキーで抑制や解除を設計します。通知だけでリリースを自動撤回するのではなく、障害と計測欠落を分けて確認します。

4. p75、欠測、比較条件を実際に計算する

教材ではnearest-rankを採用します。有限の0以上の値だけを有効観測にし、入力件数・有効件数・欠測等を区別します。空ならnullを返します。これをCrUX等の内部集計の完全再現とは扱いません。

export function percentile(values, p = 75) {
  if (!Number.isFinite(p) || p <= 0 || p > 100) throw new Error('percentile range');
  const valid = values.filter(x => typeof x === 'number' && Number.isFinite(x) && x >= 0).sort((a, b) => a - b);
  const value = valid.length ? valid[Math.ceil(valid.length * p / 100) - 1] : null;
  return { value, validCount: valid.length, excludedCount: values.length - valid.length };
}

架空のモバイル [100,120,140,160,180,200,220,240,260,280] msはp75が240ms、デスクトップ [20,30,40,50,60,70,80,90,100,110] msは90msです。二つのp75の平均は165msですが、全20件を並べた15番目は180msです。詳しい分母の扱いはパーセンタイルを平均できない理由で確認できます。

優先順位は因果ではなく仮説として残す

次は症状と次の診断を結びつけるだけの関数です。固定の「効果大・工数小」点数から売上増を算出しません。

export function nextInvestigation({ longTasks, lateHero, serverWait }) {
  if (serverWait) return 'response-start';
  if (lateHero) return 'hero-discovery';
  if (longTasks) return 'interaction-main-thread';
  return 'collect-more-evidence';
}

ラボの記録は次のように測定器を注入して残せます。呼出し時刻と終了時刻は実観測にし、未来の成功値を先に入れません。measureはローカルfixtureでも実測器でもよいですが、結果には由来を明記します。

export async function recordMeasurement(measure, clock) {
  const startedAt = clock();
  const result = await measure();
  const endedAt = clock();
  return { startedAt, endedAt, result };
}

目標値は単位を含めて表します。LCPは秒ではなくmsで受け取る前提なので2500です。達成判定には上記の分布・端末群・期間を伴わせます。

export const targets = {
  LCP: { maximum: 2500, unit: 'ms' },
  INP: { maximum: 200, unit: 'ms' },
  CLS: { maximum: 0.1, unit: 'unitless' },
};

画像改善を一つ試して観測へ戻る

画像改善では「すべてlazy」にしません。次は先頭画像か後方画像かを指定して属性を決める教材です。実際の画像変換やLCP要素の発見は行っていません。

export function imagePolicy({ width, height, isHero }) {
  if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) throw new Error('dimensions');
  return { width, height, loading: isHero ? 'eager' : 'lazy',
    fetchPriority: isHero ? 'high' : 'auto' };
}

変更前後の算数もゼロや欠測を拒否します。架空のLCP 3000msから2400msは20%の短縮です。ただし端末・流入・期間・キャッシュ・同時変更が違えば、変更による因果効果とは断定できません。

export function compareDuration(beforeMs, afterMs) {
  if (!Number.isFinite(beforeMs) || beforeMs <= 0 || !Number.isFinite(afterMs) || afterMs < 0) throw new Error('duration');
  return { beforeMs, afterMs, reductionPercent: (beforeMs - afterMs) / beforeMs * 100 };
}

変更票には「対象商品ページの先頭画像候補を見直す」「期間と端末群を固定」「有効件数と欠測率を併記」「表示崩れや取得失敗が増えたら戻す」「担当者と再評価日」を埋めます。改善率の数字だけで完了にせず、計測が壊れて値が消えた可能性も確認してください。

実装候補は画像最適化とJavaScript・CSSの最適化、業務との結びつけ方はCore Web Vitalsと事業評価、収集基盤の背景はGA4入門へ進めます。監視を含む全体手順と公開ツール一覧も、対象と利用条件を確認して活用してください。

よくある質問(FAQ)