記事一覧のテンプレートを別の用事で読んでいて、いちばん上の大きなカードにも loading="lazy" が付いているのに気づいたんですよね。一覧の画像を全部同じ書き方で回しているので、先頭のカードも一緒に lazy になっていました。
「LCP の画像に lazy を付けてはいけない」は、自分も人に言ったことがあります。でも「外すと何ミリ秒縮むの」と聞かれたら、正直なところ答えられない。fetchpriority や preload を足すとどれくらい変わるのかも、手元で測ったことがありませんでした。
そこで、同じページの書き方だけを変えて測ることにしました。
先に結果を書くと、「lazy を外せば速くなる」は半分しか当たっていませんでした。
回線を絞るところで、いきなりつまずいた
計測は使い捨ての Docker コンテナの中で閉じました。Chromium 147 をヘッドレスで動かし(Playwright 1.59.1)、nginx 1.22.1 から HTTP/1.1 と HTTP/2 で配ります。画面は 412x823 で、毎回まっさらなコンテキストから開いています。
回線は Lighthouse のモバイル相当(下り 1.6Mbps・上り 750kbps・RTT 150ms)に絞るつもりで、最初は tc netem を叩きました。
$ tc qdisc add dev lo root netem delay 75ms rate 1600kbit limit 1000
Error: Specified qdisc kind is unknown.
Docker Desktop の WSL2 カーネル(5.15.167.4)には netem が入っていなかったんです。DevTools のスロットリングに逃げる手もあるんですが、あれはブラウザの中で要求単位にかける方式なので、サーバーが送る順番を変える効果が見えにくいんです。今回はそこが知りたかったので、Node で TCP のリレーを書いて、全部の接続で 1 本の細い回線を分け合う形にしました。
// relay.mjs(抜粋): 全接続で1本の細い回線を共有し、片道 RTT/2 の遅延を足す
// 改善の余地があります。TCP のスロースタートもパケットロスも再現していません
tick(now) {
this.credit = Math.min(this.credit + (now - this.last) * this.bytesPerMs, PACKET * 4);
this.last = now;
let idleRounds = 0;
while (this.credit >= 1 && this.flows.length && idleRounds < this.flows.length) {
this.next %= this.flows.length;
const flow = this.flows[this.next];
this.next += 1;
if (flow.chunks.length === 0 || now < flow.notBefore) { idleRounds += 1; continue; }
idleRounds = 0;
const buf = this.take(flow, Math.min(PACKET, Math.floor(this.credit)));
this.credit -= buf.length;
this.inFlight.push({ at: now + this.delayMs, flow, buf });
}
// 以降: 遅延の期限が来たものから相手へ write する
}
絞れているかは curl で確かめました。804,299 バイトの画像は転送に 4.03 秒(1.6Mbps)、TLS が終わってから最初の 1 バイトまでが 154ms。ここは狙いどおりです。CPU のスロットリングはかけていません。
検証ページは 2 種類です。
- 軽いページ: HTML・CSS 40KB・モジュール JS 60KB・画像 200KB の計 309KB
- 重いページ: HTML・CSS 60KB・同期 JS 120KB・モジュール JS 150KB・上にアイコン 8 枚・画像 200KB・下にサムネイル 12 枚(各 30KB)の計 955KB
最初の集計では lazy が優勝した
最初に 5 通りを流して表を見たとき、軽いページの lazy が LCP 1,162ms で断トツの 1 位でした。素の <img> は 2,466ms。lazy を付けたほうが倍速いわけです。
そんなはずはないので LCP の要素を出してみると、<img> ではなく <p> でした。転送量も 108KB しかない。200KB の画像がまだ届いていません。
loading="lazy" の画像は load イベントを待たせません。自分のスクリプトは load の 600ms 後に LCP を読んでいたので、画像が届く前に段落の値を読んで終わっていた、というオチです。
await page.goto(url, { waitUntil: 'load' });
// lazy の画像は load を待たせないので、画像そのものが届くまで待ってから読む
await page.waitForFunction(
(pattern) => performance.getEntriesByType('resource').some((e) => new RegExp(pattern).test(e.name)),
'hero',
);
await page.waitForTimeout(400);
const lcp = await page.evaluate(() => window.__lcp.at(-1)); // addInitScript の PerformanceObserver で貯めた最後の1件
// 実行結果(軽いページ・HTTP/2):
// 直す前 lazy 1,162ms(P) 素の img 2,466ms(IMG)
// 直した後 lazy 2,476ms(IMG) 素の img 2,460ms(IMG)
今思えば、数字だけ記録していたのが雑でした。LCP は「どの要素か」とセットで見ないと、速くなったのか別のものを測っているのか区別がつきません。
もう一つ、最初は画像の再取得を防ぐつもりで Cache-Control: no-store を付けて配っていました。すると重いページの HTTP/1.1 で lazy と preload を組み合わせたときだけ、画像が 2 回落ちてきて転送量が 1,156KB に増え、LCP が 6.7 秒になりました。毎回まっさらなコンテキストなのでキャッシュの心配は要らないと気づき、本番に近い長期キャッシュの指定にして全部流し直しています。以下の数字はこちらです。
13 通りの結果
書き方 13 通りを 5 回ずつ流した中央値です(単位は ms。「CSS 背景 + preload(後)」だけ 4 回)。最大と最小の差は中央値で 14ms で、大きかったのは重いページの JS 挿入の 692ms でした。「軽い」「重い」は HTTP/2、「H1」は重いページの HTTP/1.1 です。
| 書き方 | 軽い LCP | 重い LCP | 重い FCP | H1 LCP | H1 FCP |
|---|---|---|---|---|---|
| 素の img | 2,460 | 4,012 | 1,864 | 5,852 | 2,596 |
| decoding=async | 2,464 | 4,016 | 1,868 | 5,848 | 2,592 |
| loading=lazy(decoding=async も) | 2,476 | 5,692 | 1,864 | 5,848 | 2,592 |
| fetchpriority=high | 2,300 | 2,984 | 1,864 | 5,008 | 3,500 |
| preload だけ(CSS の前) | 2,460 | 3,884 | 1,916 | 3,660 | 3,660 |
| preload + fetchpriority(CSS の前) | 2,304 | 2,988 | 2,988 | 3,660 | 3,660 |
| preload + fetchpriority(CSS と同期 JS の後) | 2,304 | 2,984 | 1,864 | 4,976 | 3,496 |
| lazy + preload(CSS の前) | 2,460 | 3,884 | 1,916 | 3,656 | 3,656 |
| CSS 背景(style 属性) | 2,460 | 5,684 | 1,920 | 5,848 | 2,592 |
| CSS 背景(外部 CSS) | 2,464 | 5,684 | 1,864 | 5,852 | 2,600 |
| CSS 背景 + preload(CSS の前) | 2,304 | 2,908 | 2,908 | 3,640 | 3,640 |
| CSS 背景 + preload(CSS と同期 JS の後) | 2,230 | 2,982 | 1,900 | 4,986 | 3,508 |
| JS で後から挿入 | 2,608 | 5,228 | 1,868 | 5,848 | 2,588 |
decoding="async" は、どの列も誤差でした。あとは順に見ていきます。
lazy の害は、競合がいるときだけ出る
ドキュメントには「LCP の画像は lazy にしない」と書いてあるんですが、実際やってみると、軽いページでは 2,460ms と 2,476ms で 16ms しか変わりませんでした。
画像の要求が出る時刻は 800ms から 1,154ms へ、確かに 354ms 遅れています。ところが最初の 1 バイトが届くのはどちらも 1,531ms。CSS とモジュール JS が流れている間は画像の番が来ないので、早く頼んでも待っているだけなんですよね。
重いページだと話が変わります。4,012ms が 5,692ms になって、1.7 秒遅れました。素の <img> は 24 個のうち 11 番目に届くのに、lazy は 24 番目、つまり最後です。回線が 1 本しかない以上、LCP は「画像より前に何バイト流れるか」でほぼ決まる。要求が遅いこと自体より、順番で負けることのほうが効いています。
CSS 背景も負け方は同じで 5,684ms(24 番目)。JS で後から挿す形は 5,228ms でした。
fetchpriority は順番を変える。相手がいなければ効かない
重いページの HTTP/2 で fetchpriority="high" を足すと、4,012ms が 2,984ms になりました。1 秒縮んで、FCP は 1,864ms のまま。属性 1 個でこれなら文句はありません。
DevTools の Priority 列と同じ値を CDP で取ると、理由が見えます。素の <img> は、先頭の画像なら Medium、アイコン 8 枚の後ろだと Low で始まって、レイアウトの後で High に上がる。fetchpriority="high" は最初から High です。
一方で軽いページでは 160ms しか縮みません。モジュール JS の前に出るだけだからです。HTTP/1.1 はもっと微妙で、LCP は 844ms 縮むのに、FCP が 2,596ms から 3,500ms へ 904ms 遅れました。接続ごとに帯域を分け合うので、CSS と同期 JS の取り分が減ったのだと見ています。
想定外だったのがサーバーです。Node の http2 で素直に書いた静的サーバーに替えると、同じ HTML・同じ回線で素の <img> が 5,528ms(4,920〜5,688ms とばらつく)、fetchpriority="high" が 3,684ms でした。nginx より 0.7〜1.5 秒遅い。属性はあくまでお願いで、最後に送る順番を決めるのはサーバーなんですよね。
preload は「見つける」だけ。置き場所を間違えると FCP を壊す
preload だけ足しても、重いページの LCP は 3,884ms で 128ms しか縮みません。優先度は Low から始まるので、早く見つかっても順番はすぐには上がらない。ただ、lazy を外せない事情があるなら、lazy と preload の組み合わせで同じ 3,884ms まで戻せます。
fetchpriority="high" を付けて CSS より前に置くと、LCP は 2,988ms になりますが、FCP も 2,988ms まで遅れます。1,864ms だったので 1,124ms の悪化です。画像自体は 2,320ms に届いているのに、追い越された CSS と同期 JS を待たないと何も描けません。
同じ 1 行を後ろへ動かすだけで、LCP 2,984ms・FCP 1,864ms になりました。
<head>
<link rel="stylesheet" href="/a/app.css">
<script src="/a/tag.js"></script>
<!-- CSS と同期 JS の後ろに置く。前に置くと FCP が 1,864ms から 2,988ms に遅れた -->
<link rel="preload" as="image" href="/a/hero.jpg" fetchpriority="high">
</head>
HTTP/1.1 はもっと敏感で、軽いページでも CSS より前に置くだけで FCP が 1,164ms から 2,188ms に遅れます。LCP だけ見ていると 144ms の改善に見えるので、気づきにくいやつです。
CSS 背景には preload がよく効きました。5,684ms が、CSS の後ろに置いた preload で 2,982ms(FCP 1,900ms)です。
回線を 10Mbps・RTT 40ms まで上げると差は縮みます。重いページで素の <img> が 756ms、lazy が 1,036ms、fetchpriority="high" が 604ms。軽いページは 508ms、516ms、484ms で、ほぼ誤差でした。
圧縮より順番が効くページがある
画像を軽くする話とも比べました。どちらも HTTP/2 の LCP です。
| 画像の大きさ | 重い 素の img | 重い lazy | 重い fetchpriority | 軽い 素の img | 軽い lazy | 軽い fetchpriority |
|---|---|---|---|---|---|---|
| 40KB | 3,064 | 4,880 | 2,080 | 1,660 | 1,672 | 1,356 |
| 200KB | 4,012 | 5,692 | 2,984 | 2,460 | 2,476 | 2,300 |
| 800KB | 7,636 | 8,728 | 6,604 | 5,504 | 5,512 | 5,500 |
重いページでは、200KB を 40KB まで圧縮して 948ms、fetchpriority="high" を足して 1,028ms 縮みます。40KB に圧縮しても lazy のままなら 4,880ms で、200KB のまま順番を上げた 2,984ms に負けます。
軽いページは逆で、圧縮の 800ms に対して属性は 160ms。800KB まで太らせると、属性を何にしても 5.5 秒台でした。どちらが先かはページで変わるので、「とりあえず圧縮」も「とりあえず fetchpriority」も外すことがあります。
下の画像の lazy は外さない
ここまでだと lazy が悪者に見えるので、逆も測りました。手元の本番ビルドで、記事一覧の lazy を 146 枚全部外したものです。LCP は 1,262ms で、一覧ページの中では最高記録。ところが転送量は 14MB、load は 70.7 秒でした。LCP だけ見ていると、全部 eager が優勝してしまいます。
検証ページでも、サムネイル 12 枚を lazy にすると、HTTP/2 の LCP は 4,012ms と 4,092ms でほぼ同じまま、転送量が 955KB から 712KB へ、load が 5,676ms から 4,169ms へ減りました。HTTP/1.1 は LCP も 5,852ms から 4,940ms に縮みます。
もう一つ踏みやすいのが、PC 用とスマホ用に同じ写真を 2 枚置いて、display:none で切り替えている画面です。スマホ幅で測りました(4 回の中央値)。
| 書き方 | 転送量 | LCP |
|---|---|---|
| 2 枚とも lazy | 202KB | 1,950 |
| 2 枚とも属性なし | 1,006KB | 3,140 |
| 2 枚とも fetchpriority=high | 1,006KB | 5,962 |
| picture で 1 枚にする | 202KB | 1,932 |
lazy を外すと、隠れている 800KB も落ちてきます。両方に fetchpriority="high" を付けると、見えない画像と回線を奪い合って LCP は 5,962ms。<picture> に書き換えると 1 枚で済みました。
自社の画面に当てはめる
Astro の <Image /> は、何も書かないと lazy です。手元の 6.0.8 で出力を確かめました。先頭だけ扱いを変える一覧の書き方も、一緒に載せておきます。
<Image src={hero} alt="" />
<!-- 出力: loading="lazy" decoding="async" -->
<Image src={hero} alt="" priority />
<!-- 出力: loading="eager" decoding="sync" fetchpriority="high" -->
{items.map((item, i) => (
<img
src={item.src}
alt={item.title}
loading={i === 0 ? undefined : 'lazy'}
fetchpriority={i === 0 ? 'high' : undefined}
/>
))}
<!-- 出力: 先頭は fetchpriority="high" だけ、2枚目以降は loading="lazy" だけ -->
うちの一覧は先頭カードも lazy で、記事ページの上部は CSS 背景です。本番ビルドの HTML だけを書き換えて、同じ回線で測りました(4 回の中央値。Google Fonts・alpinejs・gtag は外しているので、実際より軽い条件です)。
| 画面 | 書き方 | FCP | LCP |
|---|---|---|---|
| 記事ページ | CSS 背景のまま | 1,134 | 1,506 |
| 記事ページ | CSS の後ろに preload を 1 行 | 1,138 | 1,352 |
| 記事一覧 | 先頭カードも lazy | 1,232 | 1,558 |
| 記事一覧 | 先頭カードだけ fetchpriority=high | 1,240 | 1,270 |
差は 150〜290ms。軽いページ寄りなので、ここまでの結果どおり小さめでした。正直なところ、1.7 秒の世界を想像していたので拍子抜けしたんですが、どちらも変更は小さいので、入れる価値はあると思っています。
自分の判断基準
迷ったら、この順で見ることにしました。
- LCP の要素を確かめる。数字だけでなく要素も記録し、画像が届くまで待ってから読む
- 画面に必ず出る 1 枚だけ lazy を外す。コンポーネントの既定も疑う
- 画像の前に流れるバイトを数える。競合が多ければ
fetchpriority="high"、少なければ圧縮が先 - CSS 背景には preload を足す。CSS と同期 JS の後ろに置き、FCP も一緒に見る
- 下の画像は lazy のまま。
display:noneの 2 枚持ちは<picture>に書き換える
おわりに
今後は、一覧の先頭カードと記事ページの preload から順に入れていくつもりです。そのうえで、今回測れていない CloudFront 越しの挙動を見たいと思っています。本番は HTTP/2 で配っているので、nginx と Node のどちらに近いのかで、fetchpriority の期待値がだいぶ変わるはずなんですよね。HTTP/3 とスロースタートも手つかずです。
CDN がどの順番で送っているかを手軽に確かめる方法があったら、ぜひ教えてください。