Lighthouse のスコアは 92 点でした。緑。何度走らせても緑。
それなのに Search Console の Core Web Vitals レポートは、INP が「改善が必要」のまま動きませんでした。実機で触っても、正直そこまで遅く感じない。
この食い違いに二週間ほど振り回されました。個人開発で小さなブログサイトを運営している私自身にとって、ラボの数字を上げることに費やした時間はそのまま損失でした。フィールドの数字は 1 ミリも動かなかった。
原因は単純でした。私はずっと、実ユーザーが触っていない画面を計測していたのです。この記事は、ラボとフィールドの乖離を実ユーザー計測で埋め、遅い操作を名指ししてから Antigravity で直したときの記録です。
ラボとフィールドは別物だと腹落ちさせる
まず、Lighthouse が測っているものを正確に理解し直しました。
Lighthouse は「ラボデータ」です。あなたのマシンで、1 回、決まった条件で読み込んだ結果にすぎません。とくに INP は、ラボでは操作をほとんど発生させないため、多くの場合ゼロに近い値が出ます。緑に見えて当然なのです。
一方、Search Console が表示するのは「フィールドデータ」、つまり Chrome ユーザーエクスペリエンスレポート(CrUX)による実ユーザーの p75 です。実際に人がボタンを押し、フォームに打ち込み、リストを絞り込んだ結果の分布です。
| 観点 | ラボ(Lighthouse) | フィールド(CrUX / Search Console) |
| 計測対象 | 1 回の合成読み込み | 実ユーザーの 28 日分の分布 |
| INP の扱い | 操作が少なく過小評価されがち | 実際の操作の p75 を反映 |
| 反映速度 | 即時 | 数日〜数週間の遅延 |
| SEO 評価に使われる | いいえ | はい |
ランキングに効くのはフィールドです。だからラボが緑でもフィールドが赤いなら、信じるべきはフィールドの方でした。ここを腹落ちさせるまで、私はずっと間違った的を撃っていました。
実ユーザーの INP を自分で計測する
CrUX は便利ですが、粒度が粗く、遅延もあります。「どのページの、どの操作が遅いか」までは教えてくれません。そこで自分のサイトに実ユーザー計測(RUM)を仕込みました。
Google の web-vitals ライブラリには attribution 付きのビルドがあり、これが決定的でした。単に INP の値を返すだけでなく、その遅延を起こした具体的な要素とイベントまで教えてくれます。
// 実ユーザーの INP を発生元つきで収集する
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const attr = metric.attribution;
const payload = {
value: Math.round(metric.value), // 実際の INP(ms)
rating: metric.rating, // good / needs-improvement / poor
target: attr.interactionTarget, // 遅延を起こした DOM 要素のセレクタ
type: attr.interactionType, // pointer / keyboard
// 内訳: 入力遅延・処理時間・描画遅延のどこで時間を食ったか
inputDelay: Math.round(attr.inputDelay),
processingDuration: Math.round(attr.processingDuration),
presentationDelay: Math.round(attr.presentationDelay),
path: location.pathname,
};
// ビーコンで軽量に送信(ページ離脱時も落とさない)
navigator.sendBeacon('/api/rum', JSON.stringify(payload));
});
ここで送っている interactionTarget と 3 つの内訳が宝の地図でした。INP は「入力遅延(inputDelay)」「処理時間(processingDuration)」「描画遅延(presentationDelay)」の合計です。どこが太いかで、打つべき手がまるで変わります。
一週間ためて、path と target で集計しました。すると、Lighthouse で測っていたトップページではなく、記事一覧のタグ絞り込みで INP p75 が 410ms に達していたのです。実ユーザーの多くはそこを触っていました。私は人が来ない玄関ばかり磨いていたわけです。
計測を継続的な監視に載せる考え方は Antigravity で CLI 起動レイテンシを hyperfine で測る と同じで、まず数字の出どころを自分の手元に持つことが起点になります。
内訳を見て「重い処理」だと確定させる
名指しできた次は、410ms の内訳です。RUM の集計はこうでした。
| 内訳 | p75 の値 | 意味 |
| inputDelay | 18ms | メインスレッドは空いていた |
| processingDuration | 360ms | イベントハンドラの中が重い |
| presentationDelay | 32ms | 描画は問題なし |
処理時間が 360ms。つまり原因は読み込みでもレンダリングでもなく、イベントハンドラ内の同期処理でした。タグをクリックした瞬間に、数千件の記事を毎回メインスレッドでフィルタしていたのです。
ここまで来ると、Antigravity に渡す指示が具体的になります。「サイトを速くして」ではなく、「記事一覧のタグ絞り込みハンドラの processingDuration が p75 で 360ms。この同期フィルタを分割して INP を下げてほしい」と言える。原因を名指しできると、エージェントの提案精度が段違いに上がります。
名指しした 1 箇所だけを Antigravity で直す
Antigravity のチャットに、RUM で特定した具体的な情報をそのまま渡しました。返ってきたのは、フィルタ処理を入力応答から切り離す修正です。
// 改善前: クリックのたびに全件を同期フィルタ(processingDuration を押し上げる)
function TagFilter({ articles }: { articles: Article[] }) {
const [tag, setTag] = useState('');
const filtered = articles.filter((a) => a.tags.includes(tag));
return (
<>
<TagButtons onSelect={setTag} />
<ArticleGrid items={filtered} />
</>
);
}
// 改善後: useTransition で絞り込みを非緊急扱いにし、入力応答を先に返す
import { useState, useTransition, useMemo } from 'react';
function TagFilter({ articles }: { articles: Article[] }) {
const [tag, setTag] = useState('');
const [isPending, startTransition] = useTransition();
// 重い計算は保持しつつ、選択の反映を緊急描画から外す
const filtered = useMemo(
() => articles.filter((a) => a.tags.includes(tag)),
[articles, tag],
);
const handleSelect = (next: string) => {
// クリックの描画を止めず、絞り込みは後追いで反映
startTransition(() => setTag(next));
};
return (
<>
<TagButtons onSelect={handleSelect} pending={isPending} />
<ArticleGrid items={filtered} />
</>
);
}
useTransition の肝は、クリックの視覚的な反応(ボタンの押下状態)を即座に返し、重いフィルタ結果の反映を非緊急に回す点です。処理そのものを速くしたのではなく、ユーザーの操作に対する「次の描画」を先に届けたことで INP が下がります。INP を悪化させるハンドラの見つけ方は Antigravity で遅い動作を切り分けるトラブルシューティング にも通じる考え方です。
RUM の実測では、この画面の INP p75 は 410ms から 90ms に下がりました。約 78% の改善です。ラボのスコアはほぼ変わっていません。当然です。ラボは最初からこの操作を測っていなかったのですから。
フィールドの改善を回帰テストで守る
最後に、せっかく下げた数字を守る仕組みを入れました。
フィールドデータは反映に数週間かかるため、これを CI の合否には使えません。代わりに、ラボ環境で「実際にその操作を発生させる」合成テストを組みました。実ユーザーが触る操作を CI で再現し、INP に近い指標を回帰監視します。
# .github/workflows/inp-regression.yml — 実操作を再現して INP を回帰監視
name: INP Regression
on:
pull_request:
branches: [main]
jobs:
interaction:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build && npm run start &
# RUM で特定した「タグ絞り込み」を Playwright で実際にクリックし、
# イベントの processingDuration を計測してしきい値でゲートする
- run: node scripts/measure-interaction.mjs --path=/articles --action=tag-filter --budget-ms=200
大事なのは、CI で測る操作を「RUM が赤かった操作」に一致させることです。人が触らない画面をいくら CI で守っても、フィールドは動きません。Lighthouse CI を回帰パイプラインに組み込む具体例は Antigravity で Lighthouse CI のパフォーマンス回帰を止める にまとめています。
まとめ — 直す前に「誰の、どの操作か」を突き止める
二週間の遠回りで学んだことは、ひとつです。ラボが緑でフィールドが赤いなら、直す対象を決めているのはフィールドだということ。
Lighthouse は入口の健康診断としては優秀です。けれど実ユーザーの INP を名指しできるのは RUM だけでした。web-vitals の attribution で発生元の要素とイベントの内訳まで取れば、Antigravity への指示は「速くして」から「この操作のこの内訳を下げて」へ変わります。エージェントは、曖昧な願いより具体的な症状によく効きます。私はこの「計測してから名指しする」順序を強く推奨します。
次に Core Web Vitals が赤くなったら、私はまず実機を触るのをやめて RUM の集計を開きます。遅い操作は、たいてい自分が磨いていない画面にいます。同じ乖離で足を止めている方の助けになれば嬉しいです。