やあ。今日も今日とて、プロダクトのパフォーマンスチューニングに頭を悩ませているところかい?
「LCP(Largest Contentful Paint)の数値がどうしても改善しない」「スマホでスクロールすると、なぜか画面がカクつく」。こうしたフロントエンドのパフォーマンスの壁にぶつかった時、君たちは真っ先に何を疑うだろうか?
JavaScriptの肥大化? CSSのセレクタの複雑さ?
もちろんそれらも原因になるが、実務の現場で意外に見落とされがちで、かつ致命的なボトルネックになりやすいのが「画像のデコード処理」と「レンダリング優先度の制御」だ。
今回は、ブラウザの内部で画像がどのように料理され、メインスレッドにどんな悪夢をもたらすのか。そして、それをネイティブの力でどう优雅に回避し、LCPを限界まで切り詰めるのかについて、ブラウザの裏側の仕組みから実務のベストプラクティスまで、みっちり解説していこう。
—
1. ブラウザの裏側で何が起きているのか? DOMツリー構築から画像デコードまでの暗黒面
まず、ブラウザがHTMLを受け取ってから画面に画像を描画するまでのフローを、おさらいも含めて解剖してみよう。ここはフロントエンドエンジニアとしての共通言語を持っておくべき領域だ。
1. HTMLのパースとDOMツリーの構築:
メインスレッドがHTMLを上から順に読み込み、トークナイザーが文字を解釈してDOMノードを生成していく。
2. CSSOMツリーの構築とレンダーツリーの結合:
途中でスタイルシートに出会えばCSSOMを作り、DOMとマッチングさせて実際の見た目を決定する「レンダーツリー」を作る。
3. レイアウト(リフロー)とペイント:
各要素が画面上のどこに配置されるべきかを計算し(レイアウト)、ピクセル単位で画面に描き込む(ペイント)。
さて、ここまでは基本だ。問題はこの後、「画像」が登場した瞬間から始まる。
圧縮されたデータは、そのままでは画面に描けない
私たちが普段何気なく使っているJPEG、PNG、そしてWebPやAVIF。これらはすべて、ネットワーク帯域を節約するために激しく圧縮されたバイナリデータだ。
ブラウザが `` タグを見つけると、ネットワーク層にリクエストを飛ばして画像データをダウンロードする。しかし、ここで忘れてはならない決定的な事実がある。
「ブラウザは、圧縮されたままのJPEGやWebPを画面に描画することはできない」
画面にピクセルを描き出すためには、その圧縮されたデータを一度、「非圧縮のビットマップ(生データの塊)」に引き伸ばす必要がある。この変換作業こそが、世に言う「画像デコード(Image Decoding)」だ。
メインスレッドを殺す「同期デコード」の恐怖
かつての、あるいは何も考えずに実装されたブラウザの挙動では、この画像デコード処理は恐ろしいことにメインスレッド(JavaScriptが動き、DOMをいじり、レイアウトを計算する、あのたった一つの生命線)の上で直列に実行されていた。
想像してほしい。
ユーザーが重い高解像度画像をいくつも含むページにアクセスしたとする。HTMLのパースが終わり、画像データのダウンロードが完了した瞬間、メインスレッドは突然「数メガバイトもあるJPEGのデコード作業」を強制される。
CPUはフル回転し、メインスレッドは完全にブロックされる。
結果どうなるか? ユーザーがスクロールしようとしても画面はピクリとも動かず、クリックしても反応しない。いわゆる「Jank(カクつき)」の完成だ。LCPの計測値は跳ね上がり、ユーザーはイライラしてタブを閉じ、コンバージョン率は音を立てて崩れ去る。これが、画像デコードが引き起こす現場の悲劇だ。
—
2. ブラウザの進化:非同期デコードと「ネイティブLazy Loading」の仕組み
この悲惨な状況を救うため、近年のモダンブラウザのアーキテクチャは劇的な進化を遂げた。
非同期デコード(`decode()` API)の裏側
現代のブラウザは、画像デコードをメインスレッドから切り離し、バックグラウンドのワーカースレッド(オフスクリーンスレッド)に逃がす仕組み(あるいはメインスレッドのアイドルタイムに細切れに処理する仕組み)を手に入れた。
これにより、デコード中であってもUIの応答性が保たれるようになった。さらに、JavaScriptから明示的に非同期デコードを制御できる `Image.decode()` という強力なAPIも標準化されている。
`loading=”lazy”` という名の救世主
そして、レンダリング最適化のゲームチェンジャーとなったのが、HTMLの `loading` 属性だ。
昔は、画面の遥か下(ファーストビューの外)にある画像遅延読み込みのために、わざわざIntersection Observerを使った重いJavaScriptライブラリを導入し、スクロールイベントを監視して……と、パフォーマンスを上げるためにパフォーマンスを落とすような本末転倒な実装が横行していた。
しかし、現在はネイティブで `loading=”lazy”` がサポートされている。
ブラウザはレンダリングの初期段階において、この属性を持つ画像を発見すると、「おっと、こいつはまだ画面に入っていない(=Viewportの外にある)な。なら、ネットワークリクエストもデコードも、今のリソースを食いつぶしてまで急ぐ必要はないな」と判断し、優先度をグッと下げる。
結果として何が起きるか?
1. ファーストビューに必要なクリティカルなリソース(HTML、CSS、LCP対象の画像など)に帯域とCPUパワーが集中する。
2. ユーザーがスクロールしてファーストビューに近づくまで、遅延対象の画像はネットワークにも載らず、当然デコードもされない。
メインスレッドの負荷は劇的に軽減され、LCPやTBT(Total Blocking Time)といったCore Web Vitalsの指標が美しく改善していくというわけだ。
—
3. 現場で使える! 実務的ベストプラクティスとコード例
理論はこれくらいにして、明日から君のプロジェクトでそのまま使える実践的なコードを見ていこう。
「じゃあ、すべての画像に `loading=”lazy”` を貼ればいいんだな?」
……ちょっと待ってくれ。それだけでは、シニアの仕事としては100点満点中60点だ。
⚠️ 注意すべきアンチパターン:LCP画像へのLazy Loading
ここが現場で最もやらかしやすい罠だ。ファーストビュー(最初に画面に表示されるエリア)にあるLCP候補の画像に `loading=”lazy”` を指定しては絶対にダメだ。
なぜなら、ブラウザが「あ、これはlazyだから、DOM構築が終わってレイアウトされて、ビューポートに近づくまでダウンロードしなくていいや」と判断してしまう結果、肝心のLCP画像の読み込み開始が遅れ、かえってLCPの数値を悪化させるという本末転倒な事態を招くからだ。
ファーストビューの画像には `loading=”eager”`(または無指定)、ファーストビューより下の画像には `loading=”lazy”`。この使い分けが鉄則だ。
では、実際のコンポーネント実装例を見てみよう。

機能紹介
スクロールした先にあるコンテンツ群です。


コードのポイント解説
1. `fetchpriority=”high”` の併用:
LCP画像に対してこれを与えると、ブラウザのプリローダーが他のリソースよりも優先してその画像をダウンロードしにいく。現代の高速化には欠かせない属性だ。
2. `decoding=”async”` の明示:
画像デコードを完全に非同期(別スレッド)で行うようブラウザに明示的なヒントを与える。デフォルトでもモダンブラウザは自動判定してくれることが多いが、明記することで意図が明確になる。
3. `width` と `height` によるCLS対策:
画像がロードされる前に領域が確保されるため、画像が表示された瞬間に周囲のテキストやレイアウトがガタッとズレる現象(Cumulative Layout Shift)を防げる。パフォーマンス向上とUX改善は表裏一体なのだ。
—
4. まとめ
Webブラウザのレンダリングパイプラインと画像デコードの仕組み、そしてネイティブLazy Loadingの挙動について、解像度は上がっただろうか?
フロントエンド開発の本質は、ただ動くコードを書くことではなく、「ブラウザという優秀なレンダリングエンジンが、いかにストレスなく効率的に仕事を行える環境を整えてやるか」という点にある。
JavaScriptの重いライブラリに頼る時代は終わった。ブラウザのネイティブ仕様を正しく理解し、適切な属性(`loading=”lazy”`, `fetchpriority=”high”`, `decoding=”async”`)を適材適所に配置してやるだけで、見違えるようなパフォーマンスの向上を勝ち取ることができる。
さあ、君のプロジェクトのコードベースを開いて、不要なJS製lazy loadingライブラリを剥ぎ取り、ネイティブの力に置き換えてみよう。ユーザー体験の劇的な変化に、きっとチームメンバーも驚くはずだ。
実装でまた壁にぶつかったら、いつでも私に相談してくれ。健闘を祈る!

コメント