Webパフォーマンス改善はCore Web Vitalsを測ってから直す:LCP/INP/CLSの優先順位
速くした“つもり”で離脱率が下がらない人へ。Core Web Vitals(LCP/INP/CLS)の閾値、web-vitalsでの計測、直す順番を実例コードで整理したハブ記事。
「サイトが重いので速くしました」と報告したのに、問い合わせの離脱率がぴくりとも動かない。
僕がやらかした典型がこれです。開発機のLighthouseでスコアが58から91に上がって、得意げにクライアントへ送った。でも本番のSearchConsoleを見たら、モバイルのLCPは赤いまま。原因は単純で、僕は「自分のPCの数字」を「ユーザーの数字」だと思い込んでいたんです。
Webの速さは、気合いやセンスで上げるものではありません。どの数字が悪いかを先に特定して、悪い1つだけを直す作業です。やみくもに useMemo を撒いたり画像を全部 priority にしたりすると、むしろ遅くなります。この記事は、その「測ってから直す」の入口になるハブとして書きました。各論(画像・JS・フォント・ローディング)は、それぞれの詳しい記事に送ります。
この記事の要点
- Webの速さは1つの数字ではない。LCP(表示)/ INP(反応)/ CLS(ズレ) の3つを分けて見る。
- 目標ラインは LCP 2.5秒以内・INP 200ms以内・CLS 0.1以下。これは体感ではなくGoogleの基準。
- 直す順番は「測る → 一番赤い指標を1つ → その犯人に効く改善 → 再計測」。複数を同時にいじらない。
- ラボ計測(Lighthouse)だけ見ると本番とズレる。実ユーザー計測(web-vitals) を併用する。
- 各論は深掘り記事へ。画像なら遅延読み込み、JSなら分割、フォントなら
font-display。
「速い」を3つに分解する
まず大前提を1つ。「速い」を1つの数字で語ると失敗します。表示は速いのにボタンが効かない、画像は出たのに広告でレイアウトが飛ぶ——これらは全部「遅い」ですが、原因も直し方も別物です。
Googleが体験の指標として定めているのが Core Web Vitals です。中身は3つだけ。
- LCP(Largest Contentful Paint): ページで一番大きな主役(ヒーロー画像や見出し)が見えるまでの時間。「待たされ感」の指標。
- INP(Interaction to Next Paint): クリックやタップのあと、画面が次に描き変わるまでの重さ。「反応が鈍い感」の指標。2024年にFID(最初の操作だけ見る旧指標)を置き換えました。
- CLS(Cumulative Layout Shift): 読み込み中にボタンや文章がガクッとズレる量。「押し間違え」を生む不快感の指標。
数字の意味と公式の閾値はweb.dev Core Web Vitalsが一次情報です。実装で迷ったら、まずここを開いてください。
そしてこの3つには、Googleが定めた「良い/要改善/悪い」のラインがあります。体感ではなく基準があるのが大事なところで、ここを共通言語にすると改善の議論が一気に楽になります。
| 指標 | 何を測るか | 良い | 要改善 | 悪い |
|---|---|---|---|---|
| LCP | 主役が見えるまで | 2.5秒以内 | 2.5〜4.0秒 | 4.0秒超 |
| INP | 操作後の反応の重さ | 200ms以内 | 200〜500ms | 500ms超 |
| CLS | 読み込み中のズレ | 0.1以下 | 0.1〜0.25 | 0.25超 |
アプリ側ではこれに加えて、APIのp75(75パーセンタイル=遅い方から数えて上位25%が体感する遅さ)、DBクエリの本数、JavaScript転送量も見ます。平均値だけ追うと、回線の細いユーザーや古い端末が数字の裏に隠れてしまう。僕が本番で赤いままだったのも、まさにこれでした。
直す順番を間違えない(測る→1点→再計測)
ここが記事の背骨です。改善は順番が9割で、順番を守らないと努力が空回りします。
- 測る: 直したい画面で、まずLCP/INP/CLSと、必要ならAPIのp75を記録する。これが「変更前のスナップショット」になる。
- 一番赤い指標を1つ選ぶ: 全部直そうとしない。LCPが4秒なら、まずLCPだけ。
- その指標の“犯人”を特定する: LCPの犯人はたいてい画像かフォントかサーバー応答。INPなら重い同期処理。CLSならサイズ未指定の要素。
- 犯人に効く最小の改善を1つ入れる: 詳しい手当ては各論記事へ(後述)。
- 同じ方法で再計測する: 効いた変更だけ残す。効かなければ戻す。
下の図がこのループです。地味ですが、これを回せるかどうかが素人とプロの分かれ目だと思っています。
flowchart LR
Measure["測る (スナップショット)"] --> Pick["一番赤い指標を1つ"]
Pick --> Find["犯人を特定"]
Find --> Patch["最小の改善を1つ"]
Patch --> Verify["同じ方法で再計測"]
Verify --> Keep["効いた変更だけ残す"]
指標と「まず疑う犯人」「送り先の記事」を対応させると、こうなります。この表が、このハブ記事のいちばん使ってほしい部分です。
| 赤い指標 | まず疑う犯人 | 主な手当て(詳しくは各論へ) |
|---|---|---|
| LCP | 重いヒーロー画像 / 遅いサーバー応答 / フォント待ち | 画像のサイズ最適化・優先読み込み、フォントのfont-display |
| INP | クリック時の重い同期JS / 大きすぎるバンドル | 重い処理の分割、不要JSの削減 |
| CLS | width/height未指定の画像・広告・遅延フォント | 画像に寸法指定、領域の予約、フォント差し替えの抑制 |
各論の深掘りはこちらに分かれています。画像まわりは画像のloading=lazyで本文が遅くなる罠と画像ギャラリーの遅延読み込みとライトボックス。JS削減はReact.lazyとdynamic importでコード分割とtree shakingでバンドルを削る。フォントのチラつきはfont-displayとサブセットで止める。体感速度を上げるローディングはスケルトンスクリーンの実装です。このハブで「どこが悪いか」を切り分けたら、犯人に対応する記事へ進んでください。
ラボ計測とフィールド計測を両方見る
計測には2種類あって、片方だけだと僕のように足をすくわれます。
ラボ計測(Lighthouse) は、固定された通信・端末条件で再現性高く測ります。Chrome DevToolsのLighthouseタブかLighthouse公式ドキュメントのCLIで動きます。修正の効果を「同じ条件で」比べるのに向いています。ただし、あなたの開発機の回線とユーザーのスマホは別物です。
フィールド計測(実ユーザー計測) は、実際の訪問者の端末で出た値を集めます。SearchConsoleのCore Web Vitalsレポートが代表で、本番の“本当の数字”です。僕の失敗はラボだけ見てフィールドを見なかったこと。逆に言えば、両方見ていれば防げました。
そして、フィールドのLCP/INP/CLSは自分のコードからも取れます。Googleの公式ライブラリweb-vitalsを使うと数行です。これを入れておくと、Lighthouseの一発勝負ではなく、本番ユーザーの分布で改善を確認できます。
<!-- index.html などに置くだけで、実ユーザーのCore Web Vitalsを計測できる -->
<script type="module">
// Googleの公式ライブラリをCDNから読み込む(本番はnpm導入を推奨)
import {
onLCP,
onINP,
onCLS,
} from "https://unpkg.com/web-vitals@4?module";
// 計測値を1件ずつ受け取るコールバック
function report(metric) {
// metric.name は "LCP" / "INP" / "CLS"
// metric.rating は "good" / "needs-improvement" / "poor"
console.log(`[CWV] ${metric.name}=${Math.round(metric.value)} (${metric.rating})`);
// 本番では収集先へ送る。離脱時でも欠けないよう sendBeacon を使う
const body = JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating });
navigator.sendBeacon?.("/api/vitals", body);
}
onLCP(report);
onINP(report); // INPはページ滞在中の操作で更新され、離脱時に確定する
onCLS(report);
</script>
ブラウザのDevToolsコンソールを開いて操作すると、[CWV] LCP=... のように出ます。rating が poor の指標が、あなたが最初に直すべき犯人です。
アプリ固有の遅いAPIは自分で測る
Core Web Vitalsはフロントの体験指標ですが、LCPやINPの裏でサーバーやAPIが足を引っ張っていることは多いです。LighthouseはそこのAPI単体までは細かく教えてくれません。なので、API応答を直接測る小さな道具を1つ持っておくと、切り分けが一気に速くなります。
Node.js 18以降なら、次のファイルをそのまま保存して動きます。修正の前後で同じコマンドを叩けば、p75の変化が数字で残ります。
// measure-url.mjs : 指定URLを複数回叩き、平均とp75を出す
import { performance } from "node:perf_hooks";
const url = process.argv[2] ?? "https://example.com/";
const runs = Number(process.argv[3] ?? 5);
// 1回分の計測。キャッシュを無効化して素の応答時間を測る
async function measureOnce() {
const start = performance.now();
const res = await fetch(url, { cache: "no-store" });
await res.arrayBuffer(); // 本文の受信完了までを計測に含める
return { status: res.status, ms: performance.now() - start };
}
const samples = [];
for (let i = 0; i < runs; i += 1) {
samples.push(await measureOnce());
}
samples.sort((a, b) => a.ms - b.ms);
const avg = samples.reduce((sum, s) => sum + s.ms, 0) / samples.length;
const p75Index = Math.floor((samples.length - 1) * 0.75); // 遅い側75%の代表値
const p75 = samples[p75Index].ms;
console.table(
samples.map((sample, index) => ({
run: index + 1,
status: sample.status,
ms: sample.ms.toFixed(1),
}))
);
console.log(`avg=${avg.toFixed(1)}ms p75=${p75.toFixed(1)}ms`);
node measure-url.mjs http://localhost:3000/api/products 7
p75を使うのは、平均だと「たまに刺さる遅さ」が消えてしまうからです。多くのユーザーが体感する遅さは、平均よりp75に出ます。この数字が、後述するキャッシュ改善やDB見直しの効果を判定する物差しになります。
よくある詰まりと、最初の一手
現実の改善は銀の弾丸が1発あるのではなく、小さな詰まりを順に外していく地道な作業です。代表的なパターンと、僕がまず打つ手を並べます。
| 画面 | よくある原因 | 最初の一手 |
|---|---|---|
| SaaSダッシュボードの初期表示が重い | 全ウィジェットのAPIを同時実行、巨大なグラフライブラリ | 重要なものだけ先に出し、残りは遅延。グラフはコード分割で後回し |
| ECの商品一覧が遅い | 画像が大きい、在庫・レビュー取得がN+1 | 画像の寸法とselect/includeを見直す |
| メディア記事のLCPが悪い | ヒーロー画像が遅い、広告タグが先に走る | 主役画像を優先、サードパーティは遅延 |
| フォントでガクッとズレる | Webフォント差し替えでCLS悪化 | font-displayで差し替えを制御 |
N+1とは、一覧を1回取ったあと、各行ごとに追加クエリが何十回も走る状態です。1件なら速くても、100件で急に重くなる。サーバー側で詰まっているか切り分けたいときは、上の measure-url.mjs でAPIのp75を測れば一発でわかります。
キャッシュは速いが、正しさを壊しやすい
サーバー側の定番改善がキャッシュ(同じ結果を短時間だけ保存して再利用する仕組み)です。よく効きますが、設計を雑にやると古い価格や他人のデータを返す事故になります。仕組みを掴むため、Expressで「素のAPI」と「キャッシュ済みAPI」を並べてみます。
npm init -y
npm install express
node cached-api.mjs
// cached-api.mjs : 意図的に遅いAPIと、メモリキャッシュ版を比較する
import express from "express";
import { performance } from "node:perf_hooks";
const app = express();
const cache = new Map();
const ttlMs = 30_000; // 30秒だけ保持(TTL)
// 800ms待つ重いデータ取得を模した関数
async function loadProducts() {
await new Promise((resolve) => setTimeout(resolve, 800));
return [
{ id: 1, name: "Starter Plan", price: 1200 },
{ id: 2, name: "Pro Plan", price: 4800 },
];
}
// キャッシュがあれば再利用、なければ取得して保存
async function cached(key, loader) {
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) return { data: hit.data, cache: "hit" };
const data = await loader();
cache.set(key, { data, expiresAt: now + ttlMs });
return { data, cache: "miss" };
}
app.get("/api/products/raw", async (_req, res) => {
const start = performance.now();
const data = await loadProducts();
res.json({ cache: "none", ms: performance.now() - start, data });
});
app.get("/api/products/cached", async (_req, res) => {
const start = performance.now();
const result = await cached("products", loadProducts);
res.json({ ...result, ms: performance.now() - start });
});
app.listen(3000, () => {
console.log("Open http://localhost:3000/api/products/raw");
});
別ターミナルで先ほどの計測ツールを当てると、差が数字で出ます。
node measure-url.mjs http://localhost:3000/api/products/raw 3
node measure-url.mjs http://localhost:3000/api/products/cached 3
キャッシュは「何を入れてよくて、何を入れたら危ないか」が全てです。価格・在庫・権限・個人情報は、速さより正確さが優先。ログインユーザーごとに違う情報を共有キャッシュに入れると、他人のデータが見える事故になります。入れるなら、キャッシュキーにユーザーIDを含める・TTLを短くする・更新時に消す、をセットで設計してください。
失敗しやすい落とし穴5つ
僕や周りが実際に踏んだものだけ挙げます。
- Lighthouseのスコアだけ追う。ラボの固定条件は実ユーザーとズレます。SearchConsoleと
web-vitalsのフィールド値を必ず併用。 - キャッシュで正しさを壊す。価格・在庫・権限・個人情報を雑にキャッシュしない。キー・TTL・削除を先に設計する。
useMemoやmemoを全部に撒く。効く場所だけに入れる。計測せず増やすと依存配列のバグが増えるだけです。Reactの再描画対策はコード分割で重いUIごと後回しにする方が効くことも多い。- 画像の優先度を間違える。ファーストビューの画像だけ優先し、下の画像まで
priorityにしない。詳細は画像のloading=lazyの罠へ。 - バンドルを刻みすぎる。分割は効きますが、細かすぎるとリクエスト過多で逆効果。tree shakingで不要コードを落とす方が先のことも多い。
よくある質問
Q. LCP・INP・CLSのどれから直すべきですか? A. 数字で一番悪い1つからです。要改善・悪い判定が出ている指標を直すと、体験への効果が大きい。全部に手を広げると効果の判定ができなくなります。
Q. LighthouseのスコアとSearchConsoleの数字が食い違うのはなぜ? A. Lighthouseはラボ(固定条件)、SearchConsoleはフィールド(実ユーザー)だからです。改善判断はフィールド寄りで、効果検証は同条件のラボで、と使い分けます。
Q. INPが悪いとき、最初に疑うのは? A. クリック時に走る重い同期処理と、肥大化したJavaScriptです。重い処理を分割し、不要なJSを削るのが基本。詳しくはコード分割とtree shakingを。
Q. CLSはどうすれば下がりますか?
A. ズレる要素にあらかじめ場所を確保します。画像や動画にwidth/heightを指定し、遅延読み込みの広告やフォント差し替えで領域が動かないようにします。フォント由来のズレはfont-displayで抑えられます。
Q. キャッシュを入れれば速くなりますよね? A. 速くはなりますが、入れてよいデータかの判断が先です。ユーザーごとに違う情報や、価格・在庫のような正確さが命のデータは慎重に。共有キャッシュは情報漏えいの事故源になります。
実際に試した結果
この記事のサンプルを手元で動かすと、800ms待たせた /api/products/raw は素直に800ms台、/api/products/cached は2回目以降が数ms台まで落ちます。web-vitals をページに仕込むと、開発機では見えなかった本番INPのpoorがコンソールに出てきて、ようやく「直すべき犯人」が見えました。
冒頭の失敗以来、僕は「速くしました」とは言わなくなりました。代わりに「LCPを4.2秒から2.1秒に、API p75を920msから180msに下げました」と数字で言う。測る→一番赤い1点を直す→同じ方法で再計測する。この型を守るだけで、改善は再現可能になり、報告も信用されるようになります。各指標の犯人が分かったら、この記事から各論へ進んで、1つずつ落としていってください。
もし自分のサイトでどの指標から手を付けるか迷ったら、対象URLと現状のCore Web Vitals、遅いAPIのログを持って研修・相談に来てもらえれば、初回から具体的な切り分けに入れます。手を動かしながら学びたい人は教材一覧もどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。