Core Web Vitals、半年放置したら40点台に落ちていた話【2026年改善実録】

機能追加のたびにじわじわ悪化するCWV、経験ありませんか?LCP・INP・CLSが本番で崩壊した実例と、チームで3ヶ月かけて立て直した記録をそのまま残しておきます。

先日、本番サービスのPageSpeed Insightsを久しぶりに開いたら、INPスコアが赤判定になっていて焦った。リリース前は80点超えてたはずなのに、機能追加を重ねた半年で気づいたら40点台に落ちていた。

これは「じわじわ悪化するパターン」の典型で、気づいた時には直すのがしんどいやつだ。チームで3ヶ月かけて本番環境のCore Web Vitalsを本気で改善したので、その実録を書いておく。2026年現在のブラウザ・フレームワーク事情も踏まえた話なので、古い記事とは少し違う観点が入ってくると思う。

2026年のCore Web Vitalsで何が変わったか

2024年にINP(Interaction to Next Paint)がFIDに置き換わってから2年が経った。うちのチームも当初は「FIDより厳しくなった」と聞いてたけど、実際に計測してみると想像以上に状況が違っていた。

INPはユーザーの全インタラクションの応答時間を追跡するので、「ごくまれに起きる重い処理」が普通に引っかかる。FIDは最初の操作だけを見ていたから、ページ読み込み後の動的処理はスルーされてたんですよね。

2026年時点でのCWV閾値をまとめるとこうなる。TTFBはCWVの公式指標ではないけど、LCPに直結するので実質的に無視できない。

指標GoodNeeds ImprovementPoor
LCP≤ 2.5s2.5s〜4.0s> 4.0s
INP≤ 200ms200ms〜500ms> 500ms
CLS≤ 0.10.1〜0.25> 0.25
TTFB≤ 800ms800ms〜1800ms> 1800ms
xychart-beta
    title "改善前後のCWVスコア比較(PageSpeed Insights)"
    x-axis [LCP改善前, LCP改善後, INP改善前, INP改善後, CLS改善前, CLS改善後]
    y-axis "スコア (0-100)" 0 --> 100
    bar [42, 87, 38, 82, 61, 94]

この改善に3ヶ月かかった。正直、最初の1ヶ月はほぼ計測と原因特定に費やした。「改善作業してます」と言いつつ、ひたすらDevToolsを眺めてた期間がある。

LCP改善:画像とフォントで詰まった話

一番インパクトが大きかったのはLCPの改善だった。うちのサービスはメインビジュアルに大きな画像を使っていて、LCPのターゲット要素がそれになっていた。

まず疑ったのは画像フォーマット。WebPは当然使っていたけど、2026年現在はAVIFの対応率がかなり上がってきた。Chromeは言わずもがな、SafariもiOS 16以降でAVIF対応済みなので、実質的にAVIFファーストで書けるようになっている。WebPと比べて30〜40%の追加圧縮が見込めるので、今から新規プロジェクトを始めるならAVIFを第一選択にすべきだと思う。

<!-- Before -->
<img src="/hero.webp" alt="ヒーロー画像" width="1200" height="630" />

<!-- After -->
<picture>
  <source
    srcset="/hero-400.avif 400w, /hero-800.avif 800w, /hero-1200.avif 1200w"
    type="image/avif"
  />
  <source
    srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
    type="image/webp"
  />
  <img
    src="/hero-1200.webp"
    alt="ヒーロー画像"
    width="1200"
    height="630"
    fetchpriority="high"
    loading="eager"
  />
</picture>

fetchpriority="high" は地味に効いた。ブラウザのプリロードスキャナーに対して「これを最優先でフェッチしろ」と明示できる。指定していない状態では、他のリソースと同じ優先度で読み込まれてしまっていた。

Next.js 15を使っているプロジェクトでは <Image> コンポーネントがこれを自動でやってくれるんだけど、カスタムコンポーネントを作ってた箇所では素通りしてたのが盲点だった。(Next.js 15の詳細な挙動については以前書いた記事にも触れているので、参考に)

次に刺さったのがフォントだった。Google Fontsを @import でCSSに書いていたのを発見した時は正直げんなりした。

/* ダメな書き方 - レンダリングをブロックする */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap');
<!-- 正しい書き方 - preconnect + link rel=preload の組み合わせ -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
  rel="preload"
  as="style"
  href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap"
/>
<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap"
  media="print"
  onload="this.media='all'"
/>

ただ、2026年現在は自前ホスティングの方が安定してパフォーマンスが出る。Google Fontsは3rdパーティドメインへの接続コストがバカにならない。Next.js の next/font を使うとビルド時にフォントをダウンロードしてself-hostingしてくれるので、これが一番楽だった。

// app/layout.tsx
import { Noto_Sans_JP } from 'next/font/google';

const notoSansJP = Noto_Sans_JP({
  subsets: ['latin'],
  weight: ['400', '700'],
  display: 'swap',
  preload: false, // 日本語フォントは全文字preloadするとでかすぎる
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja" className={notoSansJP.className}>
      <body>{children}</body>
    </html>
  );
}

これだけでLCPが2.5秒改善した。フォント最適化のインパクトを舐めてたと反省している。既存プロジェクトを見直すなら、ここを真っ先に確認すべきだと思う。

INP改善:Reactの再レンダリングと格闘した3週間

INPが一番つらかった。原因の特定が難しいし、直したつもりでまだ効いてないみたいな徒労感も何度か味わった。

まずChromeのDevToolsで「INPに関与しているインタラクション」を特定する。2026年現在のChrome DevToolsはPerformance Insightsパネルが改善されていて、どのクリックイベントがINPに影響しているかを可視化できる。

flowchart TD
    A[ユーザーがクリック] --> B[イベントハンドラー実行]
    B --> C{処理時間}
    C -->|50ms以内| D[✅ Input delay最小]
    C -->|50ms超| E[⚠️ Main thread blocked]
    B --> F[React state更新]
    F --> G[再レンダリング]
    G --> H{コンポーネント数}
    H -->|少ない| I[✅ Processing time OK]
    H -->|多い| J[⚠️ Long rendering]
    G --> K[DOM更新]
    K --> L[Presentation delay]
    L --> M[INP = B→L の合計時間]

うちのケースでは、フィルター選択時に全リストが再レンダリングされていた。500個のカードを持つリストで、フィルターのstateが親コンポーネントにあったので、フィルター変更のたびに全部が再レンダリングされていた。

// Before: フィルター変更で全カードが再レンダリング
function ProductList({ products, filter }: Props) {
  const filtered = products.filter(p => p.category === filter);
  
  return (
    <div>
      {filtered.map(product => (
        // ProductCardは毎回再レンダリング
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// After: React.memo + useMemo で不要な再レンダリングを防ぐ
const ProductCard = React.memo(function ProductCard({ product }: { product: Product }) {
  return <div className="card">{/* ... */}</div>;
});

function ProductList({ products, filter }: Props) {
  const filtered = useMemo(
    () => products.filter(p => p.category === filter),
    [products, filter]
  );
  
  return (
    <div>
      {filtered.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

React.memo はリリース当初から使ってたつもりだったけど、コンポーネントに渡している onClick ハンドラーが useCallback でメモ化されていなくて、結果的に memo が効いていなかった。これは地味に多い落とし穴だと思うので、memo を当てているのにレンダリングが減らない時はまずそこを疑うといい。

もう一つ効いたのが startTransition の活用だった。React 19から useTransition がさらに使いやすくなっているので、重い状態更新はトランジションでラップするようにした。

import { useTransition, useState } from 'react';

function FilterPanel({ onFilterChange }: { onFilterChange: (filter: string) => void }) {
  const [isPending, startTransition] = useTransition();
  const [selectedFilter, setSelectedFilter] = useState('all');

  const handleFilterChange = (filter: string) => {
    setSelectedFilter(filter); // UIの更新は即座に
    startTransition(() => {
      onFilterChange(filter); // 重い処理はトランジションで
    });
  };

  return (
    <div>
      {isPending && <div className="loading-indicator" />}
      {/* フィルターボタン */}
    </div>
  );
}

これによって、フィルタークリック時のUIが即座に反応するようになり、INPが200ms以下に収まるようになった。週次で追うと改善の手応えが分かりやすかった。

xychart-beta
    title "INP改善の推移(週次平均)"
    x-axis [Week1, Week2, Week3, Week4, Week5, Week6, Week7, Week8]
    y-axis "INP (ms)" 0 --> 600
    line [540, 520, 480, 420, 350, 280, 210, 165]

Week3〜4あたりの「思ったより下がらない」時期が精神的にきつかったが、Week5以降で startTransition 導入の効果が出てきた。

CLS改善:「なんで動くの?」問題を根絶する

CLSは原因が分散していて、一個一個潰していく作業が必要だった。うちで多かったのは以下の3パターンだ。

1. 画像の縦横比未指定

これはCLSの鉄板の原因。2026年のブラウザはCSSの aspect-ratio をサポートしているし、img要素に width/height を指定すればブラウザが読み込み前から領域を確保してくれる。

/* CSSだけで対応するパターン */
.card-image {
  aspect-ratio: 16 / 9;
  width: 100%;
  height: auto;
  object-fit: cover;
}

2. 広告・サードパーティウィジェットのサイズ未確保

遅れてロードされる要素のために、領域を事前に確保しておく。プレースホルダーがあるだけでガクつき感がぜんぜん違う。

.ad-container {
  min-height: 250px; /* 広告の最小高さを確保 */
  background: #f5f5f5; /* プレースホルダー表示 */
}

3. フォントの差し替えによるレイアウトシフト

フォールバックフォントとWebフォントのメトリクスが違いすぎてテキストがガクッと動く問題。font-display: optional を使うか、CSS Font Loading APIでフォントの読み込みを制御する。

/* size-adjust で近似させる */
@font-face {
  font-family: 'NotoSansJP-fallback';
  src: local('Hiragino Sans');
  size-adjust: 98%;
  ascent-override: 93%;
  descent-override: 20%;
  line-gap-override: 0%;
}

このフォールバックフォントの調整は正直まだ検証中で、日本語フォントは文字数が多いだけにメトリクス調整がかなり難しい。英語フォントのような明確な正解がなくて、地道にビジュアル確認するしかないのが現実だと思っている。

原因要素の特定には、以下のコードをDevToolsのConsoleに貼りながらページを操作する方法が手軽で効果的だった。

// ユーザーの実際の体験を計測するコード
const observer = new PerformanceObserver((list) => {
  let clsScore = 0;
  
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      clsScore += entry.value;
      
      // どの要素がシフトしているかを特定
      for (const source of entry.sources) {
        console.log('CLSを引き起こしている要素:', source.node);
        console.log('シフト量:', entry.value);
      }
    }
  }
  
  console.log('累積CLSスコア:', clsScore);
});

observer.observe({ type: 'layout-shift', buffered: true });

「なんとなくガクッとする」という感覚的な問題が、コンポーネント名と数値で見えるようになるのは気持ちいい。

計測基盤の整備:RUMなしで本番改善はできない

PageSpeed InsightsとLighthouseだけで満足していた時期があったけど、あれはラボデータで、ユーザーの実体験とは乖離がある。本番で本当に効果があったかどうかを確認するにはRUM(Real User Monitoring)が必要だった。

うちではweb-vitalsライブラリを使って、実際のユーザーのCWVデータを収集するようにした。

// utils/web-vitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals/attribution';

type Metric = {
  name: string;
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
  attribution: Record<string, unknown>;
};

function sendToAnalytics(metric: Metric) {
  // Google Analytics 4に送信する例
  if (typeof window.gtag === 'function') {
    window.gtag('event', metric.name, {
      value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
      metric_rating: metric.rating,
      // attributionデータで原因要素も送る
      metric_attribution: JSON.stringify(metric.attribution).slice(0, 100),
    });
  }
  
  // 自前のエンドポイントにも送る
  fetch('/api/metrics', {
    method: 'POST',
    body: JSON.stringify(metric),
    headers: { 'Content-Type': 'application/json' },
    keepalive: true, // ページ離脱時も送信完了させる
  });
}

export function initWebVitals() {
  onCLS(sendToAnalytics);
  onINP(sendToAnalytics);
  onLCP(sendToAnalytics);
  onFCP(sendToAnalytics);
  onTTFB(sendToAnalytics);
}

このデータをBigQueryに蓄積して、日次でスコアの変化を追えるようにした。(データ分析基盤についてはデータ品質管理の記事も参考になるかもしれない)

Chromeユーザーエクスペリエンスレポート(CrUX)のデータも定期的に確認している。CrUXは実際のChromeユーザーの集計データなので、PageSpeed InsightsのField Dataセクションの元データになっている。

flowchart LR
    A[ユーザーブラウザ] -->|web-vitalsライブラリ| B[RUMデータ収集]
    B --> C[自前API]
    C --> D[BigQuery]
    D --> E[Looker Studio]
    E --> F[日次レポート]
    
    A -->|Chrome集計| G[CrUXデータ]
    G --> H[PageSpeed Insights]
    H --> F
    
    F --> I[改善タスク優先度決定]

RUMを入れてから分かったのは、PageSpeed InsightsのLabデータと実際のユーザーデータがかなり乖離していたこと。特にINPは環境依存が大きくて、スペックの低いスマホユーザーでは3〜4倍の数値が出ていた。Lighthouseで100点を出しても安心できない、というのは本当にそうで、計測基盤なしのCWV改善は感触だけで戦っているのと変わらない。

まとめ

3ヶ月の改善で学んだことをまとめると、こういう感じだった。

  1. INPはFIDより格段に厳しい — 全インタラクションを監視されるので、「たまに起きる重い処理」が確実に引っかかる。startTransitionReact.memo + useCallback の組み合わせが基本的な対策になる

  2. フォント最適化のインパクトを舐めてたnext/font でセルフホスティングするだけでLCPが1〜2秒改善するケースは多い。既存プロジェクトの見直し箇所として最優先で確認すべき

  3. AVIFは今すぐ採用できる — 2026年時点でSafariを含む主要ブラウザがAVIFに対応済み。WebPと比べて30〜40%の追加圧縮が見込める

  4. LabデータとFieldデータは別物 — PageSpeed Insightsで高スコアを出しても、実際のユーザー体験とは乖離がある。RUMを入れないと本当の問題は見えない

  5. CLSはフォントのフォールバック設定が盲点 — 日本語フォントはメトリクス調整が難しいが、size-adjustascent-override の組み合わせで近似できる

まず今日できることとしては、こんな順番で試してみるのがいいと思う。

  • 5分でできる: LCP要素に fetchpriority="high" を追加する
  • 1日でできる: next/font に移行してフォントのself-hostingを有効にする
  • 1週間でできる: web-vitals ライブラリを入れてRUM計測を始める

皆さんのプロジェクトでも、久しぶりにPageSpeed InsightsとCrUXを覗いてみると予想外のスコアが出ることがある。どのあたりが詰まってるか、コメントで教えてもらえると嬉しい。セキュリティ面の実装パターンについてはOWASP Top 10の記事も合わせて読んでみてほしい。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事