Skip to content
Published on

Core Web Vitalsを実際に直す — LCP・INP・CLSを数字で下げる順序

シェア
Authors

はじめに — Lighthouseは98点なのにSearch Consoleが赤いとき

ローカルでLighthouseを回すとパフォーマンス98点です。デプロイして一週間後にSearch Consoleを開くと、「改善が必要なURL」にそのページがそのまま残っています。チームでは「グーグルのデータの反映が遅いのだろう」と流し、一か月後も状況は同じです。

二つの数字は別のものを測っています。Lighthouseは統制された環境で一度ロードした結果であり、Search Consoleが見せているのは実際の訪問者の28日分の分布です。前者を改善しても後者が付いてこないケースは非常に多く、その差がどこで生まれるのかを知ることがこの作業の半分です。

この記事はその差を突き止め、三つの指標を実際に下げる作業を効果の大きい順に扱います。

LCP・INP・CLSがそれぞれ測るものとしきい値

三つの指標はユーザー体験の異なる局面を担当します。LCPはローディング、INPは反応性、CLSは視覚的な安定性です。

指標測るもの良好の基準ラボで測定もっとも多い原因
LCP最大コンテンツ要素が描画された時点2.5秒以下可能遅いTTFB、発見が遅れるヒーロー画像
INP相互作用から次の描画まで200ms以下不可 (TBTで代理)ロングタスク、重いDOM更新
CLS予期しないレイアウト移動の累積スコア0.1以下部分的サイズのない画像、あとから挿入されるバナー

ここでほとんどの記事が抜かす二つを押さえます。

第一に、このしきい値は平均ではなく75パーセンタイルに適用されます。ページロードの75%がLCP 2.5秒以下でなければ「良好」になりません。平均LCPが2.1秒でも25%が4秒を超えれば不合格です。先ほどのパーセンタイルの話がそのまま繰り返されます。

第二に、モバイルとデスクトップは別々に評価されます。デスクトップだけを見て合格したと判断してしまうことが多いのですが、トラフィックの大半がモバイルなら改善すべきはモバイルの数字です。

CLSの計算方式も一度押さえておきます。CLSはページ寿命全体の単純な合計ではありません。移動が発生した区間を最大5秒、移動どうしの間隔1秒以内でまとめてセッション窓を作り、そのうち合計がもっとも大きい窓の値を使います。無限スクロールのページを長く開いていてもスコアが無限に大きくならない理由がこれです。

ラボデータと実ユーザーデータ — どちらが真実か

Lighthouseは端末とネットワークをシミュレートして一度ロードします。キャッシュは空で、サードパーティスクリプトはその日の応答速度に左右され、ユーザーは何もクリックしません。だからLighthouseはINPを測定できません。相互作用がないからです。代わりに総ブロッキング時間(TBT)を代理指標として見せますが、相関はあっても同じ値ではありません。

実ユーザーデータは逆です。古いAndroid端末、3G区間、すでにキャッシュのある再訪問、広告ブロッカー、そして実際のクリックが全部混ざっています。検索評価に使われるのも、ユーザーが実際に経験するのもこちらです。

結論は単純です。ラボデータは回帰検知用であり、判断の基準は実ユーザーデータです。Lighthouseのスコアを目標にすると、スコアは上がってユーザー体験はそのままという最適化をすることになります。

自前のRUM収集はスクリプト一片から始められます。

// web-vitals v4 — attribution ビルドを使えば「何が」遅かったのかまで一緒に来る
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution'

function send(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value),
    rating: metric.rating, // good | needs-improvement | poor
    nav_type: metric.navigationType,
    path: location.pathname,
    // デバイスを区別せずに合算すると何も診断できない
    device: matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop',
    conn: navigator.connection?.effectiveType,
    attribution: metric.attribution,
  })
  navigator.sendBeacon('/rum/vitals', body)
}

onLCP(send)
onINP(send)
onCLS(send)
onTTFB(send)

収集したあとは必ず経路別、デバイス別に75パーセンタイルを見ます。全体の平均ひとつではどのページを直すべきか分かりません。

LCPを下げる順序 — サーバー応答からフォントまで

LCPを丸ごと見ると手を付ける場所が見えません。四つに割れば答えが出ます。

  1. TTFB — サーバーが最初のバイトを送るまで
  2. リソースのロード遅延 — TTFB以降、LCPリソースの要求が始まるまで
  3. リソースのロード時間 — そのリソースを受け取り終えるまで
  4. レンダー遅延 — 受け取り終えてから実際に画面に描かれるまで
import { onLCP } from 'web-vitals/attribution'

onLCP(({ value, attribution: a }) => {
  console.table({
    TTFB: Math.round(a.timeToFirstByte),
    'リソース読込遅延': Math.round(a.resourceLoadDelay),
    'リソース読込時間': Math.round(a.resourceLoadDuration),
    'レンダー遅延': Math.round(a.elementRenderDelay),
    合計: Math.round(value),
    要素: a.target,
  })
})
// ┌──────────────────┬───────┐
// │ TTFB             │   412 │
// │ リソース読込遅延 │  1180 │  <-- 犯人
// │ リソース読込時間 │   340 │
// │ レンダー遅延     │    96 │
// │ 合計             │  2028 │
// └──────────────────┴───────┘

目標の配分はTTFBとリソースのロード時間がそれぞれ40パーセント程度で、二つの遅延項目は0に近くあるべきです。上の例でロード遅延が1,180msだということは、ブラウザがヒーロー画像の存在を1秒以上知らなかったという意味です。画像ファイルをいくら最適化してもこの数字は減りません。

TTFBから確認する

curl -s -o /dev/null \
  -w 'dns:%{time_namelookup}s  tcp:%{time_connect}s  tls:%{time_appconnect}s  ttfb:%{time_starttransfer}s  total:%{time_total}s\n' \
  https://shop.example.com/products/12345
# dns:0.021s  tcp:0.043s  tls:0.118s  ttfb:0.412s  total:0.588s

# キャッシュに当たったかどうかも一緒に見る — CDNミスはTTFBのよくある原因だ
curl -sI https://shop.example.com/products/12345 | grep -iE 'cache|age|server-timing'
# cache-control: public, max-age=0, s-maxage=300
# age: 0
# x-cache: MISS
# server-timing: db;dur=284, render;dur=96

Server-Timingヘッダーをサーバーから返すようにすれば、RUMデータでTTFBの内訳を見られます。これがないと、TTFBが遅いときにネットワークなのかバックエンドなのかを区別する方法がありません。

リソースのロード遅延がもっとも大きなてこである

LCP要素が画像なら、プリロードスキャナーが見つけられなければなりません。次の三つはスキャナーを無力化します。

  • CSSのbackground-imageで入れたヒーロー画像 — CSSOMができるまで発見されません。
  • JavaScriptで挿入する画像 — バンドルが実行されるまで発見されません。
  • ヒーロー画像に掛けたlazyローディング — もっともよくある自傷です。ビューポート内の画像にlazyを掛けると、要求そのものが後ろに押し出されます。
<!-- ヒーロー画像: マークアップに置き、優先度を上げ、lazy は絶対に掛けない -->
<img
  src="/hero-1200.avif"
  srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
  sizes="(max-width: 768px) 100vw, 1200px"
  width="1200"
  height="675"
  fetchpriority="high"
  decoding="async"
  alt="夏シーズンの新商品"
/>

<!-- フォールバック: 本当にマークアップに置けないなら preload でスキャナーの代わりをする -->
<link
  rel="preload"
  as="image"
  href="/hero-1200.avif"
  imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w"
  imagesizes="(max-width: 768px) 100vw, 1200px"
  fetchpriority="high"
/>

Next.jsのnext/imageを使うなら、ヒーローにpriority属性を与えることが上のfetchpriority="high"とlazy解除を同時に処理してくれます。リスト下部の画像には逆に既定のlazyを維持します。

レンダーブロッキングのリソースは減らすが丸ごとなくしはしない

よくある誤った助言が二つあります。

「すべてのCSSをインライン化せよ」。クリティカルCSSをインライン化するのは正しいですが、CSS全体をインライン化するとHTMLが大きくなってTTFB以降のパースが増え、キャッシュがまったく効かなくなります。対象は最初の画面に必要なルールに限定します。

「JavaScriptにdeferを付ければよい」。deferは実行を後回しにするだけで、ダウンロードとパースのコストはそのままです。LCP要素がJavaScriptでレンダリングされる構造なら、deferはむしろLCPを遅くします。この場合に必要なのはサーバーレンダリングです。

サードパーティスクリプトは別に扱う必要があります。タグマネージャー、チャットウィジェット、A/Bテストツールは、たいていLCP要素より先にロードされて帯域とメインスレッドを持っていきます。

# どのリソースがLCPの前に帯域を持っていったのかを見る
npx lighthouse https://shop.example.com/ \
  --only-audits=largest-contentful-paint-element,render-blocking-resources,third-party-summary \
  --output=json --output-path=./lh.json --quiet

node -e "
const r = require('./lh.json');
for (const it of r.audits['third-party-summary'].details.items.slice(0, 5))
  console.log(it.entity.padEnd(28), Math.round(it.blockingTime) + 'ms blocking', Math.round(it.transferSize / 1024) + 'KB');
"
# Google Tag Manager        412ms blocking 148KB
# Intercom                  288ms blocking 231KB
# Hotjar                    134ms blocking  92KB

フォント

フォントはLCPとCLSの両方にまたがっています。font-display: swapはテキストが見えない区間をなくしてくれますが、フォールバックフォントと実際のフォントのmetricsの差だけレイアウトが跳ねます。解法はフォールバックフォントのメトリクスを実際のフォントに合わせることです。

@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/pretendard-subset.woff2') format('woff2');
  font-weight: 400 700;
  font-display: swap;
  /* ハングル完成形 + ASCII だけをサブセットに — フォント全体は数 MB になる */
  unicode-range: U+0020-007E, U+AC00-D7A3;
}

/* フォールバックフォントのメトリクスを実際のフォントに合わせてスワップ時の移動をなくす */
@font-face {
  font-family: 'Pretendard Fallback';
  src:
    local('Apple SD Gothic Neo'),
    local('Malgun Gothic');
  size-adjust: 103%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}

body {
  font-family: 'Pretendard', 'Pretendard Fallback', sans-serif;
}

最初の画面で使うフォントファイルだけをpreloadします。複数のウェイトを全部preloadすると、ヒーロー画像と帯域を奪い合うことになります。

INPがFIDを置き換えた理由とロングタスクの分割

FIDは最初の相互作用の入力遅延だけを測定していました。つまりブラウザがイベントハンドラを実行し始めるまでの時間です。ハンドラが400msのあいだメインスレッドを掴んでいてもFIDはそれを数えませんでした。だからほとんどのサイトがFIDを簡単に通過し、通過したサイトも依然としてもたついていました。

INPは三つの区間を全部含み、最初のひとつではなくページ寿命のあいだのすべての相互作用を見ます。

  • 入力遅延: メインスレッドが別の作業中でハンドラの開始が押し出された時間
  • 処理時間: イベントハンドラが実行された時間
  • プレゼンテーション遅延: ハンドラが終わって次のフレームが描かれるまで

どの区間が問題なのかから確認します。

import { onINP } from 'web-vitals/attribution'

onINP(({ value, attribution: a }) => {
  console.table({
    入力遅延: Math.round(a.inputDelay),
    処理時間: Math.round(a.processingDuration),
    プレゼンテーション遅延: Math.round(a.presentationDelay),
    合計: Math.round(value),
    対象: a.interactionTarget,
    イベント: a.interactionType,
  })
})
// 入力遅延 34 / 処理時間 268 / プレゼンテーション遅延 92 / 合計 394
// 対象: button.filter-apply

処理時間が大きければハンドラを分割します。ここで正確に理解すべきことがひとつあります。awaitは譲歩ではありませんawaitが作るのはマイクロタスクであり、マイクロタスクは現在のタスクが終わる前に実行されるので、ブラウザが描画する隙を与えません。実際に譲歩するにはタスク境界を作らなければなりません。

// 悪い例: クリック一度でメインスレッドを380ms掴む
applyButton.addEventListener('click', () => {
  applyFilters(state) //  12ms
  rerenderTable(rows) // 260ms
  syncToServer(state) // 108ms
})

// 良い例: 目に見えるものを先に描き、残りはタスクを分けて譲る
applyButton.addEventListener('click', async () => {
  applyFilters(state)
  showPendingState() // ユーザーがすぐ見るフィードバックだけ同期実行

  await yieldToMain() // ここでブラウザが描画する
  rerenderTable(rows)

  await yieldToMain()
  syncToServer(state) // 画面と無関係な作業は最後に
})

function yieldToMain() {
  // scheduler.yield は譲歩後も優先度を保つので setTimeout より有利だ
  if ('scheduler' in globalThis && 'yield' in scheduler) return scheduler.yield()
  return new Promise((resolve) => setTimeout(resolve, 0))
}

もうひとつよく間違える地点がrequestIdleCallbackです。アイドル時間に実行されるので、画面に見えなければならない更新をここに入れるとプレゼンテーション遅延がそのまま伸びます。アイドルコールバックは分析イベントの送信やプリフェッチのように、ユーザーが待っていない作業にだけ使います。

プレゼンテーション遅延が大きい場合はたいていDOMが大きすぎます。画面外の領域のレンダリングコストをブラウザに後回しさせるか、長いリストを仮想化します。

/* 画面外のカードのレイアウトとペイントを飛ばす */
.product-card {
  content-visibility: auto;
  contain-intrinsic-size: auto 320px; /* スクロールバーのジャンプを防ぐ想定サイズ */
}

ロングタスクは常時監視できます。

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration < 120) continue
    console.warn('long task', Math.round(entry.duration) + 'ms', entry.attribution?.[0]?.name)
  }
}).observe({ type: 'longtask', buffered: true })

CLSの三大要因とそれぞれの正確な解法

CLSは原因がほぼ決まっているので、三つだけ処理すればたいてい0.1より下に下がります。

第一に、サイズが宣言されていない画像とiframe。ブラウザはリソースを受け取るまで、どれだけ場所を空けておけばよいのか分かりません。解法はwidthとheight属性を明示することです。現代のブラウザはこの二つの値からaspect-ratioを自動で計算し、レスポンシブでも場所を確保してくれます。CSSでサイズを制御する場合でも属性はそのまま残します。

<!-- 属性は実際のピクセルサイズ、CSS は表示サイズ — 両方が必要だ -->
<img src="/thumb.avif" width="640" height="360" style="width: 100%; height: auto" alt="商品" />

第二に、あとから挿入されるバナーと広告とクッキー通知。すでに描かれたコンテンツの上に要素を差し込むと、下側全体が押し出されます。解法は二つです。場所をあらかじめ確保するか、ドキュメントフローから外すことです。

/* 場所をあらかじめ取る — 広告が出なくても移動はない */
.ad-slot {
  min-height: 250px;
  contain: layout;
}

/* クッキーバナーはフローに入れずオーバーレイで浮かせる */
.cookie-banner {
  position: fixed;
  inset-block-end: 0;
  inset-inline: 0;
}

ここでよくある誤解がひとつあります。「ユーザーのクリックで開くアコーディオンもCLSに数えられる」。数えられません。ユーザー入力から500ms以内に発生した移動にはhadRecentInputフラグが付き、スコアから除外されます。ただしその時間を越えて非同期に到着したコンテンツが押し出す場合は、そのときは数えられます。

第三に、フォントのスワップ。前節のsize-adjust方式で解決します。テキストが多いページでは、これひとつがCLSの半分を占めることも珍しくありません。

追加でひとつ、アニメーションはtransformで行います。topleftwidthheightをアニメーションすると、毎フレームがレイアウト移動として計算されてスコアにそのまま積み上がります。

何が動いたのかは直接観察できます。

new PerformanceObserver((list) => {
  for (const shift of list.getEntries()) {
    if (shift.hadRecentInput) continue // ユーザー入力直後の移動は除外される
    for (const src of shift.sources) {
      console.log(shift.value.toFixed(4), src.node, src.previousRect, '→', src.currentRect)
    }
  }
}).observe({ type: 'layout-shift', buffered: true })
// 0.0821 <img class="hero"> DOMRect(0,180,...) → DOMRect(0,412,...)

パフォーマンス予算をCIに入れる

手動で測って直した改善は、二回のデプロイのうちに戻ってきます。回帰を防ぐのはゲートだけです。

ここでもよくある間違いがあります。Lighthouseの合成スコアにゲートを掛けません。スコアは複数の指標の加重平均であり、重みはバージョンによって変わります。ツールの更新だけでCIが壊れたり、逆に特定の指標が悪化したのに他の指標が良くなってスコアが維持されたりすることが起きます。ゲートは個別指標の絶対値に掛けます。

{
  "ci": {
    "collect": {
      "url": ["https://staging.shop.example.com/", "https://staging.shop.example.com/products/12345"],
      "numberOfRuns": 5,
      "settings": { "preset": "desktop" }
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "server-response-time": ["error", { "maxNumericValue": 600 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 180000 }],
        "resource-summary:image:size": ["warn", { "maxNumericValue": 400000 }],
        "unsized-images": "error",
        "uses-responsive-images": "warn"
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}
npx lhci autorun
# ✅  .lighthouseci/ directory writable
# ✅  Configuration file found
# Running Lighthouse 5 time(s) on https://staging.shop.example.com/
# ...
# Checking assertions against 2 URL(s), 5 run(s)
#   ✘  largest-contentful-paint failure  expected <= 2500, found 2884
#      https://staging.shop.example.com/products/12345
# Assertion failed. Exiting with status code 1.

numberOfRunsを5にすることが重要です。単一実行はばらつきが大きくてゲートが無作為に壊れます。Lighthouse CIは複数実行の中央値で判定します。

バンドルサイズは別の予算として管理します。LCPとINPの両方にもっとも直接的に影響する変数です。

npx size-limit
#   Path: .next/static/chunks/pages/index-*.js
#   Size: 164.2 kB with all dependencies, minified and brotlied
#   Size limit: 180 kB
#
#   Path: .next/static/chunks/pages/products/[id]-*.js
#   Size: 221.4 kB with all dependencies, minified and brotlied
#   Size limit: 180 kB
#   ✘ Size limit has been exceeded by 41.4 kB

最後に、CIゲートを通過したら終わりではありません。CIは回帰だけを捕まえます。デプロイ後の数日間、RUMの75パーセンタイルが実際に下がったかを確認してはじめて改善が完了します。ラボで400msを削ったのに実ユーザーデータがびくともしないケースは実際によくあり、そのときはたいてい実ユーザー環境にだけ存在する何かがボトルネックです。

おわりに — スコアではなく75パーセンタイルを直せ

覚えておくべきことは一文です。Lighthouseは回帰を捕まえる道具であり、直すべき対象は実ユーザーデータの75パーセンタイルです。

作業の順序もここから出てきます。まずRUMを付けて経路別、デバイス別の75パーセンタイルを見ます。もっとも悪い経路をひとつ選び、LCPなら四つに割ってどの区間かを確定し、INPなら入力遅延と処理時間とプレゼンテーション遅延のどれかを確定します。その区間に該当する解法だけを適用します。そして個別指標の絶対値でCIゲートを掛けて戻ってこないようにします。

もっともよくある一撃は依然としてヒーロー画像です。lazy属性を外してfetchpriority="high"を付け、widthとheightを明示するだけでLCPとCLSが同時に下がるページが非常に多いです。そこから始めてください。

さらに掘り下げるための資料です。