useReportWebVitals でGA4へ送って測るようにしました。送る側では「同じ値が2回送られる」問題を指標名での重複除去で防ぎ、集計する側では「全流入を合算していた」問題を検索流入への絞り込みで直しています。集め始めて3日の時点では件数が足りず、良し悪しはまだ判定していません。
お疲れ様です!IT業界で働くアライグマです!
個人開発している技術ニュースキュレーションサービス「DevPick」に、実際の利用者のページ表示の速さを継続して測る仕組みを入れました。サーバーの応答時間は以前から測っていたのですが、利用者の画面でページがどれくらいで表示され、操作にどれくらいで反応したかは、一度も測っていませんでした。
仕組み自体は数十行のコンポーネントと集計スクリプトですが、送る側と集計する側でそれぞれ1つずつ、数字を静かに狂わせる問題を踏みかけました。どちらもマージ前に直したものです。何がずれていたのか、どう直したのかを順に書いていきます。
API負荷試験では「利用者の画面の速さ」が見えなかった
DevPickでは、CIの中でAPIに負荷をかけて応答時間を測る性能試験を回しています。固定のデータ、固定の条件で測るので、コード変更の前後でサーバー側が遅くなっていないかを確かめるには向いています。ただし、この試験はブラウザの描画を測りません。設計文書でも「ブラウザ側の描画時間」は対象外と明記していました。
サーバーが速く応答しても、利用者の画面が速いとは限りません。大きな画像を読み込むのに時間がかかれば、記事の一覧はなかなか表示されません。読み込みの途中でレイアウトがずれれば、押そうとしたボタンが逃げていきます。こうした「画面の上での体験」を測るために、Googleは次の3つの指標を定めています。まとめてCore Web Vitalsと呼ばれます。
- LCP(Largest Contentful Paint):画面の中でいちばん大きな画像や文章のかたまりが表示されるまでの時間。「ページが表示された」と感じるまでの目安
- CLS(Cumulative Layout Shift):読み込み中に、表示済みの要素がどれだけ動いたか。後から画像が差し込まれて文章が下へずれる、といった現象を数値にしたもの
- INP(Interaction to Next Paint):クリックやタップをしてから、画面がそれに反応して描き変わるまでの時間
2026年10月3日に、開発機から本番を未ログインで開いて簡易的に測ったところ、トップ・トピック・記事詳細のLCPは0.32〜1.42秒、CLSは0でした。数字としては問題ありません。ただ、これは開発機の回線と性能で測った簡易的な値で、スマホの遅い回線で開いた人の体験は表していません。実際に過去には、ログイン中のトップでCLSが0.37まで悪化していたことがあり、個別に直しています。本番全体で同じような悪化が再発していないか、端末によって差がないかを、継続して見る手段はありませんでした。
そこで、本番の利用者のブラウザから実際の値を集めて、ページの種類や端末ごとに継続して見られるようにすることにしました。開発機での1回の測定(ラボデータ)ではなく、利用者の環境での測定値(フィールドデータ)を持つのが目的です。いま遅いという障害があったわけではなく、遅くなったときに気づく手段と、速くする施策を入れたときに効果を確かめる手段がない、という状態への対応です。
IT女子 アラ美評価をイベント名に入れて、標準ディメンションだけで集計する
値の収集には、Next.jsに同梱されている useReportWebVitals を使いました。中では、Googleが公開しているweb-vitalsライブラリが動いていて、ページの読み込みや操作に応じてLCP・CLS・INPの値をコールバックで渡してくれます。値と一緒に、Googleの基準による評価も付いてきます。評価は「良好(good)」「要改善(needs-improvement)」「不良(poor)」の3段階です。
送り先はGA4です。GA4では、イベントに付けた独自のパラメータを集計に使うには、管理画面でカスタムディメンションとして登録する手間があります。そこで、指標と評価をイベント名そのものに入れることにしました。こうすると、GA4に最初からある「イベント名」「ページのパス」「端末の種類」だけで、ページ種別×端末×評価ごとの件数を数えられます。
// 単純化した例
const RATING_SUFFIX: Record<string, string> = {
good: "good",
"needs-improvement": "ni",
poor: "poor",
};
// web_vital_lcp_good / web_vital_cls_ni / web_vital_inp_poor のようなイベント名にする
const name = `web_vital_${metric.name.toLowerCase()}_${RATING_SUFFIX[metric.rating]}`;
gtag("event", name, {
// LCP・INPはミリ秒。CLSは小数なので1000倍して整数で送る
value: Math.round(metric.name === "CLS" ? metric.value * 1000 : metric.value),
// 着地したページのパスだけにする(クエリとフラグメントは落とす)
page_location: `${landing.origin}${landing.pathname}`,
});
ここで page_location を上書きしているのには理由が2つあります。
1つ目は、計上先のページを正しくするためです。web-vitalsの値は、ページを読み込んだ瞬間にすべて揃うわけではありません。CLSやINPは、利用者がページを離れたりタブを切り替えたりして、ページが非表示になった時点でまとめて報告されることが多いです。DevPickはNext.jsのアプリなので、記事一覧から別のページへはページ全体を読み直さずに移動します(SPA遷移)。すると、トップで測った値が、報告の時点では記事ページにいるため、記事ページの値として計上されてしまいます。そこで、ブラウザのNavigation Timingから「最初に読み込んだページ」のURLを取り出し、そのパスで上書きしています。なお、web-vitalsはSPA遷移した先のページを測り直さないので、集まるのは最初に開いたページ(着地ページ)の値だけです。
2つ目は、URLに含まれる情報を計測に流さないためです。クエリにトークンや検索語が入っていても、パスだけにすれば送られません。さらに、メールで届くコードやトークンをURLで受け取るページ(アカウントの復旧、メールアドレスの確認、配信停止)では、そもそも何も送らないようにしました。もともとGA4自体をそのページでは読み込まない判定があったので、その判定を共通の関数にまとめ、Web Vitalsの送信でも同じ関数を使っています。判定は、着地したページと報告時点のページの両方に対して行い、どちらかが該当すれば送りません。
GA4の流入元の計上については、以前RSS記事リンクのutm_sourceで検索流入を誤計上しかけた話でも書きました。計測は、何を送るかと同じくらい「何を送らないか」を決めておく必要があると感じています。



各指標が2回ずつ送られていた ─ idではなく指標名で重複を除く
最初の実装でも、同じ値を何度も送らないための重複除去は入れていました。web-vitalsが値ごとに付ける id を覚えておき、送ったことのある id なら送らない、という作りです。
マージ前に、実際のブラウザで送信内容を確かめました。ChromiumでNext.jsの開発サーバーを開き、GA4のIDには仮の値を入れ、GA4への通信は遮断したうえで、送信内容が積まれる dataLayer を直接見る方法です。確かめたのは次の3つです。
- クエリとフラグメント付きのURLで開いたとき、LCP・INP・CLSが各1回だけ送られ、
page_locationにクエリとフラグメントが含まれないこと - 着地したページから別のページへSPA遷移した後に報告されても、着地ページの値として計上されること
- コードやトークンをURLで受け取るページでは、何も送られないこと
このうち1つ目で、各指標が2回ずつ送られていることが分かりました。原因は useReportWebVitals の登録のされ方です。開発時のReactはStrictModeでコンポーネントのマウントを2回繰り返すため、web-vitalsへのコールバック登録も2回行われます。登録ごとにweb-vitals側では別の測定として扱われ、同じ値に別々の id が振られて報告されていました。id が違うので、id による重複除去では1回目と2回目を見分けられません。StrictModeの二重実行は開発時だけですが、本番でもコンポーネントが再マウントされれば登録し直しは起きえます。
そこで、重複の判定を id から指標名に変えました。1回のページ読み込みにつき、LCP・CLS・INPはそれぞれ1回だけ送ります。
// 単純化した例
// モジュールのスコープに置くので、再マウントしても中身は残る
const sentMetrics = new Set<string>();
export function reportWebVital(metric: Metric) {
// id ではなく "LCP" / "CLS" / "INP" で判定する
if (sentMetrics.has(metric.name)) return;
// ...GA4へ送るイベントを組み立てる。送らない条件ならここで戻る
sentMetrics.add(metric.name);
gtag("event", event.name, event.params);
}
指標名で判定する副作用として、ブラウザの「戻る」でキャッシュ(bfcache)からページが復元されたときに改めて報告される値は送らなくなります。測りたいのは着地したときの体験なので、この値は対象外にしています。
もう1つ、コンポーネントの置き場所にも注意が要りました。GA4を読み込むコンポーネントは、トークンを受け取るページでは何も描画しません。その中に計測の登録を入れてしまうと、ページの種類によって登録されたりされなかったりします。そこでWeb Vitalsのコンポーネントはレイアウトに別に常設し、送るかどうかの判断は送信時の関数だけで行うようにしています。



全流入を合算していた ─ 集計時に検索流入へ絞る
集計は、GA4のData APIを呼ぶ既存の分析スクリプトにサブコマンドを足して行います。web_vital_ で始まるイベントを「イベント名」「ページのパス」「端末の種類」ごとに取り出し、ページのパスをトップ・記事・トピック・カテゴリなどの種別へまとめてから、評価ごとの件数を数えます。
この仕組みで観測したいものは、要件の段階で「検索から着地した実利用者の表示体験」と決めていました。検索結果から初めて訪れた人に、ページがどう見えたかです。ところが最初の集計は、流入元を区別せずに全部を合算していました。ブックマークから直接開いた人、SNSやRSSのリンクから来た人、検索から来た人の値が、同じ1つの数字に混ざっていたわけです。流入元によって、着地するページの種類も端末の比率も違いうるので、混ぜた数字は「検索から来た人の体験」とは別物になります。
では送信するときに流入元を付ければいいかというと、それはできません。GA4の流入元(チャネル)は、ブラウザから送る値ではなく、GA4がセッション単位で判定して付けるものだからです。送る側からは、そのセッションが最終的にどのチャネルに分類されるかは分かりません。
そこで、送る側は何も変えずに、集計するときにセッションのチャネルで絞ることにしました。GA4のData APIで、セッションのデフォルトチャネルグループが「Organic Search」のものだけを数えます。
# 単純化した例
expressions = [
{"filter": {"fieldName": "eventName",
"stringFilter": {"matchType": "BEGINS_WITH", "value": "web_vital_"}}},
]
if channel != "all":
expressions.append(
{"filter": {"fieldName": "sessionDefaultChannelGroup",
"stringFilter": {"matchType": "EXACT", "value": channel}}}
)
request = {
"dimensions": [{"name": "eventName"}, {"name": "pagePath"}, {"name": "deviceCategory"}],
"metrics": [{"name": "eventCount"}, {"name": "eventValue"}],
"dimensionFilter": {"andGroup": {"expressions": expressions}},
}
既定は「Organic Search」で、オプションで全流入(all)や、「Direct」のような特定のチャネルにも切り替えられます。出力の末尾には、どのチャネルで集計したかを必ず書くようにしました。数字だけを後から見返したときに、どの母集団の値だったか分からなくなるのを防ぐためです。
この修正も、二重送信の修正と同じく、マージ前に同じ変更の中で直したものです。検索流入の受け皿になるページをどう整えたかは、Next.jsのページネーションSEOを直した話で書いています。



30件未満は判定しない ─ 3日分の実測結果
Core Web Vitalsの合否は、平均ではなく75パーセンタイル(p75)で判定します。利用者を遅い順に並べたとき、遅い方から4分の1の位置にいる人の値が基準を満たしているか、という見方です。平均だと、一部の人がとても遅くても全体の数字に埋もれてしまうので、「4人に3人以上が良好な体験をしているか」を見るわけです。
GA4へは評価ごとの件数を送っているので、p75の値そのものは計算していません。ただ、評価は指標ごとの閾値で区切られているので、「goodが全体の75%以上」なら「p75がgoodの閾値以下」と同じ意味になります。集計スクリプトはこの関係を使って、p75の評価を次のように決めています。
# 単純化した例
MIN_SAMPLES = 30
def p75_rating(counts, total):
if total < MIN_SAMPLES:
return "insufficient" # 件数が少ないうちは良否を断定しない
if counts["good"] >= total * 0.75:
return "good"
if counts["good"] + counts["ni"] >= total * 0.75:
return "ni"
return "poor"
件数が30件未満の組み合わせは insufficient として、良否を出しません。個人開発のサービスはアクセスが少ないので、ページ種別×端末×指標に分けると、1つの組に数件しか入らないことがよくあります。数件のうち1件が遅かっただけで「不良」と表示されたら、それを見て慌てて手を入れることになりかねません。もう1つ、GA4のAPIが返す行数が上限で切れていた場合は、途中までの行で集計せずにエラーで止まるようにしています。途中までの行で割合を計算すると、件数も割合も黙って間違うからです。
実際に、2026年10月9日の朝に、10月7日から9日までの3日間を指定して集計しました。計測の仕組みがマージされたのは10月7日の夜なので、実質は2日弱のデータです。検索流入(Organic Search)に絞った結果は次のとおりです。
| ページ種別 | 端末 | 指標 | 件数 | good | p75の評価 | 平均 |
|---|---|---|---|---|---|---|
| 記事 | 全体 | LCP | 8 | 100% | insufficient | 891ms |
| 記事 | 全体 | CLS | 7 | 100% | insufficient | 0.010 |
| 記事 | 全体 | INP | 2 | 100% | insufficient | 12ms |
| 記事 | デスクトップ | LCP | 5 | 100% | insufficient | 882ms |
| 記事 | モバイル | LCP | 3 | 100% | insufficient | 907ms |
検索から来た利用者の値は、記事ページの分しかありませんでした。件数はLCPで8件と少なく、すべて insufficient です。INPが2件しかないのは、INPがクリックやタップなどの操作があったページでしか報告されないためです。記事を読んでそのまま閉じた場合は、INPの値は出ません。
同じ期間を全流入(all)で集計すると、検索流入にはなかったトップページの行が出てきます。
| ページ種別 | 端末 | 指標 | 件数 | good | 要改善 | 平均 |
|---|---|---|---|---|---|---|
| トップ | 全体 | LCP | 3 | 67% | 33% | 1,951ms |
| トップ | 全体 | CLS | 3 | 100% | 0% | 0.000 |
トップのLCPは3件中1件が「要改善」で、平均も記事ページの倍以上です。ただし、これは検索以外から来た利用者の値です。全流入で合算したままだと、検索から来た人の体験を見るつもりで、この値も一緒に読むことになります。どちらも件数が少なすぎて、遅いとも速いとも言えない段階です。それでも、母集団を分けると見える行そのものが変わる、ということは確かめられました。



同じ時期に入れた表示速度の改善と、まだ言えないこと
計測の仕組みとは別に、10月7日に本番で、遅いスマホと遅い回線を想定した条件でLCPを測っていました。ChromiumをPlaywrightで操作し、画面を393×851(デバイスピクセル比2)、下りを毎秒200,000バイト、遅延を150ミリ秒、CPUを4倍遅くした条件です。その結果、2つの原因が見つかりました。
- トップの先頭画像まで遅延読込になっていた:一覧の画像には一律で
loading="lazy"を付けていました。そのため、最初に画面に見えている先頭記事の画像も取得開始が後回しになり、先頭画像の取得開始は1,602.5ms、LCPは3,620msでした(通常の回線では同じページが780ms) - 記事画像を表示幅に関係なく原寸で配信していた:記事ページで表示幅359pxの画像に、横1,199pxで約205KBの原寸画像を返していて、LCPは3,940msでした
どちらも、10月8日に順に直しています。先に入れたのは先頭画像の優先読込です。表示中のページの先頭2件の中で、最初に画像を持つ1件だけを loading="eager"・fetchpriority="high" にして、それ以外は遅延読込のままにしました。全部の画像を優先にすると、結局どれも優先されなくなるからです。
その後に入れたのが画像の縮小です。画像を中継しているAPIに幅の指定を足し、縮小したWebPを返すようにしました。幅は320・640・960・1280の4種類だけを許し、それ以外の指定はエラーにしています。任意の幅を受け付けると、幅を変えたリクエストを並べるだけでサーバーに画像変換を延々とさせられるためです。記事カードと記事詳細の <img> には srcset と sizes を付け、端末の表示幅に合ったサイズをブラウザに選ばせています。SNSのカードや構造化データに使う画像は、原寸のままです。
この記事を書いている10月9日に、本番の画像APIを直接呼んで、縮小版が配信されていることを確かめました。
| リクエスト | 形式 | サイズ |
|---|---|---|
| 原寸(幅指定なし) | PNG | 205,224バイト |
| 幅960を指定 | WebP | 47,480バイト |
同じ記事の画像で、転送量は約77%減っています。トップの初期HTMLでも、先頭の画像だけが loading="eager"・fetchPriority="high" になり、2件目以降は loading="lazy" のままであることを確認しました。
一方で、これでLCPが良くなったとは、まだ言えません。改善はどちらも10月8日の夜にマージしたもので、前の章の3日分の集計には、改善の前と後の値が混ざっています。しかも件数は数件です。変更を入れたときに、同じ条件で再計測してLCPを比べる予定にしていましたが、その再計測の記録は見つけられませんでした。改善の効果は、計測の仕組みで件数が揃ってから、改善後の期間だけを集計して判断します。そもそも、施策の効果を数字で確かめるためにこの計測の仕組みを入れたので、ここで「速くなったはず」と書いてしまうと本末転倒になります。



まとめ
実際の利用者の表示体験を測るために、Next.jsの useReportWebVitals でLCP・CLS・INPをGA4へ送り、集計スクリプトでページ種別×端末ごとに見られるようにしました。やったことを並べると次のとおりです。
- 評価をイベント名に入れる:
web_vital_lcp_goodのような名前にして、GA4のカスタムディメンションを登録せずに集計できるようにした - 着地ページのパスだけを送る:SPA遷移後の報告を着地ページに計上し、クエリやトークンを計測に流さない。トークンをURLで受け取るページでは送らない
- 重複は指標名で除く:再登録のたびに別の
idが振られて各指標が2回送られていたため、1ページ読み込みにつき指標ごとに1回だけ送る - 母集団は集計側で絞る:流入元はGA4がセッション単位で付けるので、送る側ではなく集計時に検索流入へ絞る。出力にはどのチャネルの値かを明記する
- 件数が少ないうちは判定しない:30件未満はp75の評価を出さず、APIの行が上限で切れたら集計自体を止める
二重送信も母集団のずれも、エラーにはならず、それらしい数字が出続ける種類の問題でした。同じ値が2回数えられても、違う人たちの値が混ざっても、表やグラフはきちんと描けてしまいます。計測の仕組みを作るときは、値が出ることを確かめるだけでなく、「何を何回、誰の分として数えているか」を実際の送信内容と集計結果で確かめる必要があると感じました。
画像の縮小と先頭画像の優先読込で、転送量が減ったことは確認できています。LCPが実際に良くなったかどうかは、件数が揃ってから改善後の期間だけで判断します。












