フロントエンドパフォーマンスバジェット:Core Web Vitals最適化の6つの重要戦略
フロントエンドパフォーマンスの課題:なぜサイトは遅くなり続けるのか
2026年、フロントエンドプロジェクトの複雑さは増し続けている。LCP超過、CLSの揺らぎ、INP遅延、バンドルサイズの膨張、パフォーマンス低下の不可視化——これらの問題がユーザー体験と検索ランキングをむしばんでいる。
| 課題 | 典型的な症状 | ビジネスへの影響 |
|---|---|---|
| LCP超過 | 初回描画 > 3s | 直帰率53%上昇 |
| CLS揺らぎ | レイアウトシフト > 0.25 | 誤クリック、信頼低下 |
| INP遅延 | インタラクション応答 > 500ms | ユーザー離脱率40%増加 |
| バンドル膨張 | JS > 500KB (gzip) | 初回読み込み時間2倍化 |
| パフォーマンス低下の不可視化 | リリース後スコアが静かに低下 | 数ヶ月間問題に気づかない |
コア概念クイックリファレンス
| 概念 | 説明 | 目標値 |
|---|---|---|
| パフォーマンスバジェット | パフォーマンス指標に設定する定量化された閾値 | チーム定義 |
| LCP | Largest Contentful Paint | ≤ 2.5s |
| FID | First Input Delay | ≤ 100ms |
| CLS | Cumulative Layout Shift | ≤ 0.1 |
| INP | Interaction to Next Paint | ≤ 200ms |
| TTFB | Time to First Byte | ≤ 800ms |
| Lighthouse | Google自動パフォーマンス監査ツール | ≥ 90点 |
| RUM (Real User Monitoring) | リアルユーザーパフォーマンスデータ収集 | P75達標 |
| バンドル分析 | Bundle構成とサイズ追跡 | JS ≤ 200KB |
| コード分割 | ルート/機能ごとの読み込み単位分割 | 初回画面 ≤ 100KB |
5つの課題:なぜパフォーマンスバジェットは定着しないのか
1. 指標定義の不明確さ — 「良いパフォーマンス」についてチーム内の合意がない。LCPはラボデータかフィールドデータか?P75かP95か?
2. バジェット実行の困難さ — バジェットは定義されたが強制手段がない。PRはマージされ、超過しても誰も気にしない。
3. サードパーティスクリプトの影響 — 分析、広告、チャットウィジェットがJS実行時間の40-60%を占めるが、ほぼ制御できない。
4. モバイルパフォーマンスの格差 — デスクトップでLighthouse 95点、低価格AndroidでLCP 6秒。格差は甚大。
5. パフォーマンス低下の検出 — 継続的監視がないと、パフォーマンス低下は茹でガエルのように進行し、気づいた時には深刻な超過状態に。
戦略1:Core Web Vitals指標収集と分析
web-vitalsライブラリを使用してリアルユーザー指標を収集し、分析プラットフォームに報告:
import { onLCP, onFID, onCLS, onINP, onTTFB } from 'web-vitals';
interface PerformanceMetric {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
delta: number;
navigationType: string;
timestamp: number;
}
function reportMetric(metric: PerformanceMetric): void {
const body = JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating,
delta: Math.round(metric.delta),
navigationType: metric.navigationType,
url: window.location.href,
timestamp: metric.timestamp,
userAgent: navigator.userAgent,
connection: (navigator as any).connection?.effectiveType ?? 'unknown',
});
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/v1/metrics', body);
} else {
fetch('/api/v1/metrics', { body, method: 'POST', keepalive: true });
}
}
onLCP(reportMetric);
onFID(reportMetric);
onCLS(reportMetric);
onINP(reportMetric);
onTTFB(reportMetric);
サーバー側でP75集計後、バジェット閾値と比較:
const PERFORMANCE_BUDGET: Record<string, { good: number; poor: number }> = {
LCP: { good: 2500, poor: 4000 },
FID: { good: 100, poor: 300 },
CLS: { good: 0.1, poor: 0.25 },
INP: { good: 200, poor: 500 },
TTFB: { good: 800, poor: 1800 },
};
function evaluateBudget(
metric: string,
p75Value: number,
): 'pass' | 'warning' | 'fail' {
const budget = PERFORMANCE_BUDGET[metric];
if (!budget) return 'pass';
if (p75Value <= budget.good) return 'pass';
if (p75Value <= budget.poor) return 'warning';
return 'fail';
}
戦略2:バンドルサイズバジェットとLighthouse CI統合
CI/CDパイプラインでバンドルサイズとLighthouseスコアバジェットを強制実行:
// lighthouse-budget.json
const lighthouseBudget = {
budgets: [
{
path: '/*',
options: {
firstContentfulPaint: 1800,
largestContentfulPaint: 2500,
cumulativeLayoutShift: 0.1,
totalBlockingTime: 200,
interactive: 3500,
},
resourceSizes: [
{ resourceType: 'script', budget: 200 },
{ resourceType: 'stylesheet', budget: 50 },
{ resourceType: 'image', budget: 300 },
{ resourceType: 'total', budget: 800 },
],
resourceCounts: [
{ resourceType: 'third-party', budget: 5 },
{ resourceType: 'total', budget: 30 },
],
},
],
};
export default lighthouseBudget;
GitHub Actions統合:
# .github/workflows/lighthouse-ci.yml
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci && npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v12
with:
configPath: .lighthouserc.json
budgetPath: lighthouse-budget.json
uploadArtifacts: true
failOnBudgetExceeded: true
{
"ci": {
"collect": {
"numberOfRuns": 3,
"startServerCommand": "npm run preview",
"url": ["http://localhost:4173/"]
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }]
}
}
}
}
戦略3:画像とリソース読み込み最適化
interface ImageOptimizationConfig {
maxWidth: number;
quality: number;
formats: string[];
sizes: string;
lazyThreshold: number;
}
const imageConfig: ImageOptimizationConfig = {
maxWidth: 1600,
quality: 80,
formats: ['avif', 'webp', 'jpg'],
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px',
lazyThreshold: 0.1,
};
function generatePictureElement(
src: string,
alt: string,
width: number,
height: number,
isLcp: boolean = false,
): string {
const { formats, sizes, quality } = imageConfig;
const widths = [400, 800, 1200, 1600];
const sources = formats
.filter((f) => f !== 'jpg')
.map((format) => {
const srcset = widths
.map((w) => `${src}?w=${w}&q=${quality}&f=${format} ${w}w`)
.join(', ');
return `<source srcset="${srcset}" type="image/${format}" />`;
})
.join('\n');
const imgSrcset = widths
.map((w) => `${src}?w=${w}&q=${quality} ${w}w`)
.join(', ');
const lcpAttrs = isLcp
? 'fetchpriority="high" loading="eager"'
: 'loading="lazy"';
return `<picture>\n${sources}\n<img src="${src}?w=${width}&q=${quality}" srcset="${imgSrcset}" sizes="${sizes}" alt="${alt}" width="${width}" height="${height}" decoding="async" ${lcpAttrs} />\n</picture>`;
}
function setupResourceHints(): void {
const preconnectDomains = [
'https://fonts.googleapis.com',
'https://cdn.example.com',
];
preconnectDomains.forEach((domain) => {
const link = document.createElement('link');
link.rel = 'preconnect';
link.href = domain;
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
});
}
戦略4:コード分割とレイジーローディング戦略
import { defineAsyncComponent } from 'vue';
interface ChunkConfig {
name: string;
test: (modulePath: string) => boolean;
priority: number;
minSize: number;
}
const chunkStrategy: ChunkConfig[] = [
{ name: 'vendor-vue', test: /node_modules\/vue/, priority: 10, minSize: 0 },
{ name: 'vendor-ui', test: /node_modules\/@ui-lib/, priority: 8, minSize: 10000 },
{ name: 'vendor-utils', test: /node_modules\/(lodash|date-fns)/, priority: 5, minSize: 0 },
{ name: 'vendor-other', test: /node_modules/, priority: -10, minSize: 20000 },
];
function buildRollupOutputChunks() {
const manualChunks = (id: string) => {
if (!id.includes('node_modules')) return;
for (const chunk of chunkStrategy) {
if (chunk.test(id)) return chunk.name;
}
};
return { manualChunks };
}
const LazyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
loadingComponent: () => null,
delay: 200,
timeout: 10000,
});
const LazyEditor = defineAsyncComponent({
loader: () => import('@/components/RichEditor.vue'),
loadingComponent: () => null,
delay: 200,
timeout: 10000,
});
function setupIntersectionLazyLoad(): void {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement;
const modulePath = el.dataset.lazyModule;
if (modulePath) {
import(/* @vite-ignore */ modulePath).then((mod) => {
el.dispatchEvent(new CustomEvent('lazy-loaded', { detail: mod }));
});
observer.unobserve(el);
}
}
});
},
{ rootMargin: '200px' },
);
document.querySelectorAll('[data-lazy-module]').forEach((el) => {
observer.observe(el);
});
}
戦略5:サードパーティスクリプトのガバナンス
interface ThirdPartyScript {
name: string;
src: string;
category: 'analytics' | 'ads' | 'chat' | 'social' | 'other';
impact: 'high' | 'medium' | 'low';
loadStrategy: 'async' | 'defer' | 'lazy' | 'worker';
condition?: () => boolean;
}
const thirdPartyRegistry: ThirdPartyScript[] = [
{
name: 'google-analytics',
src: 'https://www.googletagmanager.com/gtag/js',
category: 'analytics',
impact: 'medium',
loadStrategy: 'async',
condition: () => !navigator.doNotTrack,
},
{
name: 'live-chat',
src: 'https://chat.example.com/widget.js',
category: 'chat',
impact: 'high',
loadStrategy: 'lazy',
condition: () => window.innerWidth >= 768,
},
{
name: 'ad-network',
src: 'https://ads.example.com/sdk.js',
category: 'ads',
impact: 'high',
loadStrategy: 'worker',
},
];
function loadThirdPartyScript(script: ThirdPartyScript): Promise<void> {
if (script.condition && !script.condition()) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const el = document.createElement('script');
el.src = script.src;
switch (script.loadStrategy) {
case 'async':
el.async = true;
document.head.appendChild(el);
break;
case 'defer':
el.defer = true;
document.head.appendChild(el);
break;
case 'lazy':
setTimeout(() => {
el.async = true;
document.head.appendChild(el);
}, 3000);
break;
case 'worker':
fetch(script.src)
.then((r) => r.text())
.then((code) => {
const blob = new Blob([code], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
worker.postMessage({ type: 'init' });
})
.catch(reject);
break;
}
el.onload = () => resolve();
el.onerror = () => reject(new Error(`Failed to load: ${script.name}`));
});
}
function initThirdPartyScripts(): void {
const highPriority = thirdPartyRegistry.filter((s) => s.impact === 'low');
const lowPriority = thirdPartyRegistry.filter((s) => s.impact !== 'low');
highPriority.forEach(loadThirdPartyScript);
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
lowPriority.forEach(loadThirdPartyScript);
});
} else {
setTimeout(() => {
lowPriority.forEach(loadThirdPartyScript);
}, 5000);
}
}
戦略6:パフォーマンス監視とアラートシステム
interface AlertRule {
metric: string;
threshold: number;
windowMinutes: number;
sampleSize: number;
channels: ('email' | 'slack' | 'webhook')[];
}
interface PerformanceAlert {
metric: string;
currentValue: number;
threshold: number;
affectedUsers: number;
timestamp: string;
}
const alertRules: AlertRule[] = [
{ metric: 'LCP', threshold: 4000, windowMinutes: 30, sampleSize: 50, channels: ['slack', 'email'] },
{ metric: 'CLS', threshold: 0.25, windowMinutes: 30, sampleSize: 50, channels: ['slack'] },
{ metric: 'INP', threshold: 500, windowMinutes: 60, sampleSize: 100, channels: ['slack', 'email'] },
{ metric: 'TTFB', threshold: 1800, windowMinutes: 30, sampleSize: 50, channels: ['slack'] },
];
class PerformanceMonitor {
private metricsBuffer: Map<string, number[]> = new Map();
addMetric(name: string, value: number): void {
if (!this.metricsBuffer.has(name)) {
this.metricsBuffer.set(name, []);
}
const buffer = this.metricsBuffer.get(name)!;
buffer.push(value);
if (buffer.length > 1000) buffer.shift();
this.checkAlerts(name);
}
private checkAlerts(metricName: string): void {
const rule = alertRules.find((r) => r.metric === metricName);
if (!rule) return;
const buffer = this.metricsBuffer.get(metricName) ?? [];
const recentValues = buffer.slice(-rule.sampleSize);
if (recentValues.length < rule.sampleSize) return;
const p75 = this.calculatePercentile(recentValues, 75);
if (p75 > rule.threshold) {
this.fireAlert({
metric: metricName,
currentValue: p75,
threshold: rule.threshold,
affectedUsers: recentValues.length,
timestamp: new Date().toISOString(),
});
}
}
private calculatePercentile(values: number[], percentile: number): number {
const sorted = [...values].sort((a, b) => a - b);
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
return sorted[index];
}
private fireAlert(alert: PerformanceAlert): void {
console.warn(`[Performance Alert] ${alert.metric} P75=${alert.currentValue}ms exceeds ${alert.threshold}ms`);
fetch('/api/v1/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(alert),
keepalive: true,
});
}
}
const monitor = new PerformanceMonitor();
よくある落とし穴
| ❌ やってはいけないこと | ✅ 正しいアプローチ |
|---|---|
| Lighthouseのラボデータのみ確認 | RUMフィールドデータと組み合わせて、ラボ+フィールドの二重検証 |
| バジェットをドキュメントに定義するだけ | CI/CDでfailOnBudgetExceeded: trueを強制実行 |
| LCP画像にlazy loadingを使用 | LCP要素にはloading="eager" + fetchpriority="high"が必須 |
| サードパーティスクリプトをすべて同期的に読み込み | 影響度で優先順位付け、低優先度はrequestIdleCallbackで遅延 |
| デスクトップのみ最適化 | 低価格モバイルをベースラインに—Moto G Powerなどのミッドレンジ端末でテスト |
トラブルシューティング
| 症状 | 考えられる原因 | 解決策 |
|---|---|---|
| Lighthouseスコアが変動 | ネットワーク不安定、CPUスロットリングの不一致 | 複数回実行して中央値を取得、numberOfRuns: 3を使用 |
| LCPが常に > 4s | 画像が最適化されていない、またはプリロードされていない | AVIF + fetchpriority="high" + preloadを使用 |
| CLS > 0.25 | 画像/広告にサイズ宣言がない | 常にwidth/heightまたはaspect-ratioを設定 |
| INP > 500ms | ロングタスクがメインスレッドをブロック | ロングタスクを分割、scheduler.yield()でメインスレッドを明け渡す |
| バンドルバジェットCIが機能しない | budgetPathのパスが間違っている |
相対パスを確認、JSON構文をチェック |
| web-vitalsデータが消失 | ページアンロード時にsendBeaconがキャンセルされる |
fetch + keepalive: trueをフォールバックとして使用 |
| コード分割で初回描画が遅くなる | 分割しすぎてリクエストウォーターフォールが発生 | 小さなチャンクをマージ、クリティカルパスのリソースをインライン化 |
| サードパーティスクリプトの読み込み失敗 | CSPポリシーによるブロック | Content-Security-Policyに該当ドメインを追加 |
| TTFB > 1.8s | SSRが遅い、またはCDNキャッシュミス | エッジキャッシュを有効化、SSRキャッシュ戦略を最適化 |
| フォントのフラッシュ (FOIT/FOUT) | font-display宣言がない |
font-display: swap + preloadを使用 |
高度な最適化
-
PartytownでサードパーティスクリプトをWeb Workerに移動 — 分析スクリプトなどの非クリティカルJSをメインスレッドからオフロード。INPを30-50%削減可能。
<script type="text/partytown">を設定するだけ。 -
Speculation Rulesによるプリレンダリング —
<script type="speculationrules">を使用して、ユーザーが訪問しそうなページをプリレンダリング。LCPを0.5s以下に削減可能。 -
RUMデータに基づくA/Bパフォーマンス実験 — 異なる最適化戦略を異なるユーザーセグメントに適用し、P75指標の差を比較。推測ではなくデータで意思決定。
-
Service Workerストリーミングキャッシュ — Streams APIを使用してプログレッシブなキャッシュレスポンスを実現。初回描画が完全なリソースダウンロードでブロックされない。
ツール比較
| 観点 | Lighthouse | WebPageTest | SpeedCurve | Calibre |
|---|---|---|---|---|
| 位置づけ | 自動監査 | 深度ネットワーク分析 | 継続的パフォーマンス監視 | チームコラボレーション監視 |
| データ種別 | Synthetic | Synthetic | Synthetic + RUM | Synthetic + RUM |
| デバイスエミュレーション | 限定(Throttling) | 豊富(実デバイス) | マルチリージョン・マルチデバイス | マルチリージョン・マルチデバイス |
| CI統合 | 優秀(公式CI) | 普通 | 優秀 | 優秀 |
| 費用 | 無料 | 無料枠あり | 有料 | 有料 |
| 適用シーン | クイック監査、CIゲート | ネットワーク問題の深度診断 | 長期トレンド監視 | チーム協働とアラート |
まとめと展望
フロントエンドパフォーマンスバジェットは一度きりのタスクではなく、継続的なエンジニアリングプラクティスである。2026年の主要トレンド:
- INPがFIDに代わり Core Web Vitalsの正式指標に—インタラクション応答性が新たな焦点
- パフォーマンスバジェットのCI化 — ドキュメントの取り決めからコードによる強制へ、
failOnBudgetExceededがベースライン - RUM駆動の意思決定 — ラボデータは出発点にすぎない、リアルユーザーデータこそが真実
- Web Workerオフロード — Partytownなどのソリューションでサードパーティスクリプトがメインスレッドを遅くしない
パフォーマンスバジェット体制を構築し、リリースのたびに「パフォーマンスセキュリティチェック」を行えるようにしよう。ユーザーからの苦情で問題を発見するのではなく。
オンラインツールおすすめ
- JSONフォーマッター — LighthouseレポートJSONデータのフォーマット
- ハッシュ計算ツール — キャッシュ検証用のリソースファイルハッシュ生成
- cURL to Code — APIリクエストをコードに変換、パフォーマンス監視の統合を迅速化
ブラウザローカルツールを無料で試す →