cwv-audit について
cwv-auditは、Core Web Vitalsの理解と改善に使うリファレンススキルです。開発者やサイト運営者向けに、指標の説明、問題の診断、測定方法、CrUXクエリ、監視設定をまとめています。
主な機能
- 01LCP、INP、CLSのしきい値
- 02主な原因と最適化の指針
- 03実ユーザー・ラボ測定の資料
- 04CrUX BigQueryクエリ例
- 05web-vitalsと監視の設定ガイド
活用シーン
- 01読み込みの遅さや表示の不安定さの診断
- 02CrUXデータによるサイト指標の比較
- 03Core Web Vitals監視の計画
- 04web-vitals測定の組み込み
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ cognyai/ claude-code-marketing-skills --skill cwv-auditインストール後、Claude Code で/cwv-auditを入力して読み込まれたか確認します。
詳細
Core Web Vitals リファレンス
Core Web Vitals(LCP、INP、CLS)の完全な技術リファレンスです。しきい値、一般的な原因、解決策、測定ツール、CrUX BigQuery クエリ、web-vitals ライブラリのセットアップ、SEO ランキングへの影響について説明します。
ドキュメント全文:https://cogny.com/docs/core-web-vitals
使用方法
/cwv-audit # Full CWV overview and audit guidance
/cwv-audit LCP # Deep-dive into LCP causes and fixes
/cwv-audit INP # Deep-dive into INP causes and fixes
/cwv-audit CLS # Deep-dive into CLS causes and fixes
/cwv-audit CrUX competitor # CrUX competitor comparison queries
/cwv-audit monitoring # GA4 + BigQuery monitoring setup
/cwv-audit web-vitals # web-vitals library integration guide
指示
あなたは Core Web Vitals のパフォーマンスエキスパートです。このリファレンスを使って、ユーザーが CWV 指標を理解し、パフォーマンスの問題を診断し、CrUX BigQuery クエリを作成し、モニタリングを設定し、ユーザーエクスペリエンスと SEO ランキングの向上に向けてサイトを最適化できるよう支援してください。
ユーザーから質問されたら、以下から関連するセクションを見つけ、正確で実践的な回答を提供してください。Search Console または GA4 の MCP ツールを利用できる場合は、それらを使ってユーザーのサイトの実データを取得してください。
ユーザーが特定の指標やトピックを引数として指定した場合は、その項目に焦点を当ててください。指定がない場合は、3 つすべての指標と現在のしきい値の概要を説明してください。
概要
Core Web Vitals(CWV)は、Google が検索のランキングシグナルとして使用する、ユーザー中心の 3 つのパフォーマンス指標です。読み込みパフォーマンス、インタラクティブ性、視覚的な安定性を測定します。すべてのページは、実ユーザーデータの 75 パーセンタイルで評価されます。
| 指標 | 正式名称 | 測定対象 | 良好 | 改善が必要 | 不良 |
|---|---|---|---|---|---|
| LCP | Largest Contentful Paint | 読み込みパフォーマンス | ≤ 2.5 s | ≤ 4.0 s | > 4.0 s |
| INP | Interaction to Next Paint | インタラクティブ性 | ≤ 200 ms | ≤ 500 ms | > 500 ms |
| CLS | Cumulative Layout Shift | 視覚的な安定性 | ≤ 0.1 | ≤ 0.25 | > 0.25 |
75 パーセンタイルですべての指標が「良好」のしきい値を満たすと、そのページは Core Web Vitals に「合格」します。
LCP — Largest Contentful Paint
測定対象
LCP は、ページの読み込み開始時点を基準として、ビューポート内に表示される最大の画像、動画、またはテキストブロックの描画時間を示します。
しきい値
- 良好: ≤ 2.5 秒
- 改善が必要: > 2.5 s かつ ≤ 4.0 s
- 不良: > 4.0 秒
LCP 候補要素
<picture>内を含む<img>要素<svg>内の<image>要素<video>要素(ポスター画像または最初に表示されたフレーム)url()で読み込まれたbackground-imageを持つ要素(CSS グラデーションを除く)- テキストノードまたはインラインレベルのテキスト子要素を含むブロックレベル要素
ページの読み込み中に最大の要素が変わることがあります。ユーザーが操作する前の最後のエントリーが LCP 値です。
一般的な原因と解決策
1. サーバー応答時間(TTFB)が遅い
- CDN を使用して、エッジロケーションからコンテンツを配信する
- HTML をエッジでキャッシュする(stale-while-revalidate)
- 必要なオリジンに事前接続する:
<link rel="preconnect" href="https://cdn.example.com"> - 103 Early Hints を使用する
2. レンダリングをブロックするリソース
- クリティカル CSS をインライン化し、重要度の低いスタイルシートは遅延させる
- 重要度の低い JavaScript は遅延または非同期で読み込む:
<script defer src="app.js"> - CSS のサイズを最小限にし、CSS 内で
@importを使用しない
3. リソースの読み込み時間が遅い
- LCP 画像をプリロードする:
<link rel="preload" as="image" href="hero.webp"> <picture>のフォールバックとともに最新の形式(WebP、AVIF)を使用する- LCP の
<img>にfetchpriority="high"を設定する srcsetとsizesを使ってレスポンシブ画像を使用する
4. クライアント側レンダリングの遅延
- LCP コンテンツを SSR または SSG で生成する
- 重要なルートを事前レンダリングする
- JavaScript バンドルのサイズを削減する
5. Web フォントが LCP のテキストをブロックする
font-display: swapまたはfont-display: optionalを使用する- 主要なフォントファイルをプリロードする
- 必要な文字にフォントをサブセット化する
INP — Interaction to Next Paint
測定対象
INP は、ページのライフサイクル全体にわたるすべてのクリック、タップ、キーボード操作のレイテンシを測定し、最悪の操作を表す単一の値を報告します。2024 年 3 月に、Core Web Vital として FID に取って代わりました。
しきい値
- 良好: ≤ 200 ミリ秒
- 改善が必要: > 200 ms かつ ≤ 500 ms
- 不良: > 500 ミリ秒
FID との違い
| FID | INP | |
|---|---|---|
| 対象範囲 | 最初のインタラクションのみ | すべてのインタラクション |
| 測定内容 | 入力遅延のみ | 全体のレイテンシー:入力遅延+処理+表示遅延 |
| ステータス | 廃止済み(2024年3月) | 公式の Core Web Vital |
インタラクションレイテンシーの3つのフェーズ
- 入力遅延 — ユーザー操作からイベントハンドラーの開始までの時間
- 処理時間 — イベントハンドラーの実行にかかる時間
- 表示遅延 — ハンドラーの完了から次の描画までの時間
よくある原因と対処法
1. メインスレッドをブロックする長時間タスク
scheduler.yield()またはsetTimeout(0)を使って長時間タスクを分割します- 緊急性のない処理には
requestIdleCallbackを使用します - 負荷の高い計算処理を Web Workers に移します
2. 負荷の高い JavaScript の実行
- 動的な
import()でコードを分割します - ビルド時に未使用のエクスポートをツリーシェイクします
- 重要度の低いスクリプトやサードパーティーのタグの読み込みを遅らせます
3. コストの高いイベントハンドラー
- 短時間に連続して実行されるハンドラーにデバウンスまたはスロットリングを適用します
- ハンドラー内で同期レイアウトを強制しないようにします
- 画面外のコンテンツには
content-visibility: autoを使用します
4. 大規模な DOM とコストの高い描画
- 長いリストを仮想化します(react-window、@tanstack/virtual)
- CSS の
contain: layout style paintを使用します - DOM の深さと要素数を減らします
5. SPA フレームワークでの過剰な再レンダリング
- コンポーネントをメモ化します(
React.memo、useMemo) - 緊急性のない状態更新には
startTransitionを使用します
CLS — 累積レイアウトシフト
測定内容
CLS は、ページの存続期間中に発生した予期しないレイアウトシフトスコアの合計を測定します。レイアウトシフトは、ユーザーの操作によって引き起こされることなく、表示中の要素の位置が変わったときに発生します。
しきい値
- 良好: ≤ 0.1
- 改善が必要: > 0.1 かつ ≤ 0.25
- 不良: > 0.25
セッションウィンドウの計算
- シフトはセッションウィンドウごとにグループ化されます(最大5秒、1秒間の間隔が空くとウィンドウが終了します)
- CLS = セッションウィンドウの最大スコア
- 各シフトスコア = 影響割合 x 距離割合
レイアウトシフトとして扱われるもの
- ユーザー入力によって引き起こされていない位置の変化
- CSS の
transformアニメーションは対象外です - ユーザー操作から500ms以内に発生したシフトは除外されます
よくある原因と対処法
1. 寸法が指定されていない画像や動画
<img src="hero.jpg" alt="Hero" width="800" height="400">
<style>.hero-img { aspect-ratio: 16 / 9; width: 100%; }</style>
2. 動的に挿入されるコンテンツ
.ad-slot { min-height: 250px; min-width: 300px; contain: layout style paint; }
3. FOIT/FOUT を引き起こす Web フォント
@font-face {
font-family: 'Custom Font';
src: url('custom.woff2') format('woff2');
font-display: optional; /* eliminates layout shift entirely */
}
4. 表示領域が確保されていない広告や埋め込みコンテンツ
.embed-container {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
height: 0; overflow: hidden;
}
.embed-container iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
5. 読み込みが遅いバナーや Cookie バー
position: fixedまたはstickyを使用して、コンテンツをずらさずに重ねて表示します
測定ツール
フィールドデータ(実際のユーザー)
| ツール | 説明 |
|---|---|
| CrUX | BigQuery、API、PageSpeed Insights で取得できる28日間のローリングデータ |
| Search Console CWV report | URL を「良好/改善が必要/不良」ごとにグループ化します |
| PageSpeed Insights (field section) | URL/オリジンごとの CrUX の75パーセンタイル値 |
| web-vitals library | 実際のブラウザーで CWV を測定し、任意のエンドポイントに送信します |
ラボデータ(合成)
| ツール | 説明 |
|---|---|
| Lighthouse | LCP と CLS をシミュレーションします(INP は対象外) |
| Chrome DevTools Performance panel | 3つすべての指標について、完全なフレームチャートを表示します |
| WebPageTest | フィルムストリップとウォーターフォールを使った複数ステップのテスト |
重要: ラボツールでは INP を完全に測定できません。必ずフィールドデータで検証してください。
JavaScript API
// Observe LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
console.log('LCP:', entries[entries.length - 1].startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// Observe CLS
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) console.log('Shift:', entry.value);
}
}).observe({ type: 'layout-shift', buffered: true });
// Observe INP (Event Timing API)
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.interactionId) console.log('Interaction:', entry.duration, 'ms');
}
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
web-vitals Library
インストール
npm install web-vitals
基本的な使い方
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
GA4 への送信
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToGA4(metric) {
gtag('event', metric.name, {
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
event_category: 'Web Vitals',
event_label: metric.id,
non_interaction: true,
});
}
onLCP(sendToGA4);
onINP(sendToGA4);
onCLS(sendToGA4);
カスタムエンドポイントへの送信
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name, value: metric.value, rating: metric.rating,
delta: metric.delta, id: metric.id, page: window.location.pathname,
});
if (navigator.sendBeacon) navigator.sendBeacon('/api/vitals', body);
else fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
アトリビューションビルド
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
onLCP((metric) => {
console.log('LCP element:', metric.attribution.element);
console.log('TTFB:', metric.attribution.timeToFirstByte);
console.log('Resource load delay:', metric.attribution.resourceLoadDelay);
console.log('Element render delay:', metric.attribution.elementRenderDelay);
});
onINP((metric) => {
console.log('Target:', metric.attribution.interactionTarget);
console.log('Input delay:', metric.attribution.inputDelay);
console.log('Processing:', metric.attribution.processingDuration);
console.log('Presentation delay:', metric.attribution.presentationDelay);
});
onCLS((metric) => {
console.log('Largest shift target:', metric.attribution.largestShiftTarget);
console.log('Largest shift value:', metric.attribution.largestShiftValue);
console.log('Load state:', metric.attribution.loadState);
});
CrUX BigQuery クエリ
オリジンごとの CWV スコア
SELECT
origin,
form_factor.name AS device,
ROUND(SAFE_DIVIDE(
SUM(IF(lcp.start < 2500, lcp.density, 0)), SUM(lcp.density)
), 4) AS lcp_good_pct,
ROUND(SAFE_DIVIDE(
SUM(IF(inp.start < 200, inp.density, 0)), SUM(inp.density)
), 4) AS inp_good_pct,
ROUND(SAFE_DIVIDE(
SUM(IF(cls.start < 0.1, cls.density, 0)), SUM(cls.density)
), 4) AS cls_good_pct
FROM `chrome-ux-report.all.202501`,
UNNEST(largest_contentful_paint.histogram.bin) AS lcp,
UNNEST(interaction_to_next_paint.histogram.bin) AS inp,
UNNEST(cumulative_layout_shift.histogram.bin) AS cls
WHERE origin = 'https://www.example.com'
GROUP BY origin, device
ORDER BY device
ページタイプごとの CWV(URL レベル)
SELECT
url,
form_factor.name AS device,
ROUND(p75_lcp / 1000, 2) AS p75_lcp_sec,
p75_inp AS p75_inp_ms,
ROUND(p75_cls, 3) AS p75_cls
FROM `chrome-ux-report.materialized.device_summary`
WHERE origin = 'https://www.example.com'
AND url LIKE '%/product/%'
AND date = (SELECT MAX(date) FROM `chrome-ux-report.materialized.device_summary`)
ORDER BY p75_lcp DESC
LIMIT 50
CWV の経時的な推移
SELECT
yyyymm,
ROUND(SAFE_DIVIDE(
SUM(IF(lcp.start < 2500, lcp.density, 0)), SUM(lcp.density)
), 4) AS lcp_good_pct,
ROUND(SAFE_DIVIDE(
SUM(IF(inp.start < 200, inp.density, 0)), SUM(inp.density)
), 4) AS inp_good_pct,
ROUND(SAFE_DIVIDE(
SUM(IF(cls.start < 0.1, cls.density, 0)), SUM(cls.density)
), 4) AS cls_good_pct
FROM `chrome-ux-report.all.*`,
UNNEST(largest_contentful_paint.histogram.bin) AS lcp,
UNNEST(interaction_to_next_paint.histogram.bin) AS inp,
UNNEST(cumulative_layout_shift.histogram.bin) AS cls
WHERE origin = 'https://www.example.com'
AND _TABLE_SUFFIX BETWEEN '202401' AND '202501'
AND form_factor.name = 'phone'
GROUP BY yyyymm
ORDER BY yyyymm
競合比較
WITH origins AS (
SELECT origin FROM UNNEST([
'https://www.yoursite.com',
'https://www.competitor-a.com',
'https://www.competitor-b.com'
]) AS origin
)
SELECT
o.origin, form_factor.name AS device,
ROUND(SAFE_DIVIDE(
SUM(IF(lcp.start < 2500, lcp.density, 0)), SUM(lcp.density)
), 4) AS lcp_good_pct,
ROUND(SAFE_DIVIDE(
SUM(IF(inp.start < 200, inp.density, 0)), SUM(inp.density)
), 4) AS inp_good_pct,
ROUND(SAFE_DIVIDE(
SUM(IF(cls.start < 0.1, cls.density, 0)), SUM(cls.density)
), 4) AS cls_good_pct
FROM origins o
INNER JOIN `chrome-ux-report.all.202501` crux ON crux.origin = o.origin,
UNNEST(largest_contentful_paint.histogram.bin) AS lcp,
UNNEST(interaction_to_next_paint.histogram.bin) AS inp,
UNNEST(cumulative_layout_shift.histogram.bin) AS cls
WHERE form_factor.name = 'phone'
GROUP BY o.origin, device
ORDER BY o.origin, device
SEO への影響
ページ エクスペリエンスのランキングシグナル
CWV は、HTTPS、モバイルフレンドリー、煩わしいインタースティシャルがないことと並び、Google のページ エクスペリエンス シグナルの一部です。
- CWV は同点の場合の決め手として機能します。関連性と品質が同等であれば、CWV が優れているページのほうが上位に表示される可能性があります
- CWV は支配的なシグナルではありません。優れたコンテンツや被リンクより優先されることはありません
- Google は、ラボのスコアではなく、CrUX のフィールドデータにおける75 パーセンタイルを使用します
- モバイルの CWV はモバイルのランキングに、デスクトップの CWV はデスクトップのランキングに影響します
最適化の優先順位
まずモバイル向けに最適化します。処理能力が低く、ネットワークが遅いため、通常は最も厳しい制約が課されます。
最適化パターン
画像の最適化
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="Hero" width="1200" height="600"
fetchpriority="high" decoding="async">
</picture>
<img src="hero-800.webp" alt="Hero"
srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w"
sizes="(max-width: 600px) 400px, (max-width: 900px) 800px, 1200px"
loading="lazy" decoding="async" width="1200" height="600">
重要なルール: LCP 画像は遅延読み込みしないでください。代わりに、画像に fetchpriority="high" を設定します。
フォントの最適化
<link rel="preload" as="font" type="font/woff2" href="/fonts/main.woff2" crossorigin>
@font-face {
font-family: 'Main Font';
src: url('/fonts/main.woff2') format('woff2');
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}
JavaScript の最適化
<script defer src="/js/app.js"></script>
<script async src="https://analytics.example.com/tag.js"></script>
// Dynamic import for code splitting
const Chart = await import('./components/Chart.js');
// Non-urgent work
requestIdleCallback(() => { initializeAnalytics(); });
CSS の最適化
/* Limit recalculation scope */
.card { contain: layout style paint; }
/* Skip rendering off-screen content */
.below-fold { content-visibility: auto; contain-intrinsic-size: 0 500px; }
サードパーティスクリプトの管理
- 重要でないサードパーティスクリプトは
window.loadの後に読み込みます - 必要なサードパーティのオリジンには
<link rel="preconnect">を使用します - 可能であれば、サーバーサイドタグ付けを使用します
- Chrome DevTools の Coverage タブで、サードパーティの影響を監査します
GA4 + BigQuery のモニタリング設定
CWV を GA4 に送信する
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
function sendToGA4(metric) {
gtag('event', metric.name, {
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
event_category: 'Web Vitals',
event_label: metric.id,
metric_rating: metric.rating,
debug_target: metric.attribution?.interactionTarget
|| metric.attribution?.element
|| metric.attribution?.largestShiftTarget
|| '(not set)',
non_interaction: true,
});
}
onLCP(sendToGA4);
onINP(sendToGA4);
onCLS(sendToGA4);
GA4 BigQuery エクスポートから CWV をクエリする
WITH vitals AS (
SELECT
DATE(TIMESTAMP_MICROS(event_timestamp)) AS date,
event_name AS metric_name,
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'value') AS metric_value,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'metric_rating') AS rating,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'debug_target') AS debug_target,
device.category AS device_type
FROM `project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 28 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name IN ('LCP', 'INP', 'CLS')
)
SELECT
date, metric_name, device_type,
COUNT(*) AS samples,
APPROX_QUANTILES(metric_value, 100)[OFFSET(75)] AS p75,
COUNTIF(rating = 'good') / COUNT(*) AS good_pct,
COUNTIF(rating = 'poor') / COUNT(*) AS poor_pct
FROM vitals
GROUP BY date, metric_name, device_type
ORDER BY date DESC, metric_name, device_type
最もパフォーマンスの低いページを特定する
WITH page_vitals AS (
SELECT
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page,
event_name AS metric_name,
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'value') AS metric_value,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'metric_rating') AS rating,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'debug_target') AS debug_target
FROM `project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 28 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name IN ('LCP', 'INP', 'CLS')
)
SELECT
REGEXP_EXTRACT(page, r'^https?://[^/]+(/.*)') AS path,
metric_name,
COUNT(*) AS samples,
APPROX_QUANTILES(metric_value, 100)[OFFSET(75)] AS p75,
COUNTIF(rating = 'poor') / COUNT(*) AS poor_pct,
APPROX_TOP_COUNT(debug_target, 3) AS top_debug_targets
FROM page_vitals
WHERE page IS NOT NULL
GROUP BY path, metric_name
HAVING samples >= 50
ORDER BY poor_pct DESC
LIMIT 50
リソース
- web.dev Core Web Vitals: https://web.dev/articles/vitals
- CrUX のドキュメント: https://developer.chrome.com/docs/crux/
- web-vitals ライブラリ: https://github.com/GoogleChrome/web-vitals
- CrUX BigQuery クックブック: https://developer.chrome.com/docs/crux/bigquery/
- PageSpeed Insights: https://pagespeed.web.dev/
- Cogny ドキュメント全文: https://cogny.com/docs/core-web-vitals