LCPを70%削減した方法
Core Web Vitalsを改善するための高度な手法を学ぶ

Web Workersと2段階の画像読み込みでLCP指標を改善する
多くの場合、表示可能なビューポート内の大きな画像要素がLargest Contentful Paint要素になります。画像のリサイズ、画像の圧縮、WebP変換、LCP要素のプリロードなど、Lighthouseのすべてのベストプラクティスを適用した後でも、Largest Contentful PaintがCore Web Vitalsの基準を満たさない場合があります。
これを修正する唯一の方法は、2段階の読み込みやWeb Workersを使用したページのスレッド化といったより高度な戦術を使用して、メインスレッドのリソースを解放することです。

最終レビュー: Arjen Karel (2026年3月)
背景について
私はページスピードの専門家であり、自分のウェブサイトが私のショーケースです。ホームページでは、自分のサイトが世界最速のサイトであると誇りを持って宣言しています。そのため、ページを可能な限り高速に読み込み、サイトからページスピードのあらゆる要素を絞り出す必要があるのです。
今日ご紹介するテクニックは、専任で優秀な開発チームのサポートがなければ、一般的な(WordPressの)サイトでは実現できないかもしれません。ご自身のサイトでこのテクニックを再現できない場合でも、私がページスピードについてどのように考え、どのようなことを考慮しているかを学ぶために、この記事を読むことをお勧めします。
問題: 表示可能なビューポート内の大きな画像
表示可能なビューポート内の大きな画像は、しばしばLargest Contentful Paint要素になります。このLCP画像がCore Web Vitalsの基準を満たさないことはよくあります。私は日常的にこのような結果を目にしています。

この要素を画面にすばやく表示させるには、いくつかの方法があります:
- LCP要素のプリロード。LCP画像をプリロードすることで、この画像ができるだけ早くブラウザで利用できるようになります。これと
fetchpriority="high"を組み合わせて、この画像を他のリソースよりも優先するようにブラウザに伝えます。 - レスポンシブ画像の使用。モバイルデバイスにデスクトップサイズの画像を提供しないようにします。
- 画像の圧縮。画像を圧縮することで、画像のサイズを大幅に削減できます。
- 次世代画像フォーマットの使用。 WebPなどの次世代画像フォーマットは、ほとんどすべてのケースでJPEGやPNGなどの古いフォーマットを上回るパフォーマンスを発揮します。
- クリティカルレンダリングパスの最小化。LCPを遅延させる可能性のあるJavaScriptやスタイルシートなどのレンダリングブロックリソースをすべて排除します。
残念ながら、これらすべての最適化を行っても、LCPの指標がCore Web Vitalsの監査を通過しない場合があります。なぜでしょうか?画像のサイズだけでも、LCPのresource load durationフェーズを遅らせるには十分だからです。
解決策: 2段階の読み込みとWeb Workers
私が(自分のサイトの他のすべての問題を最適化した後に)実装した解決策は、2段階の画像読み込みです。
アイデアはシンプルです。最初のレンダリング時に、最終的な高品質画像とまったく同じ寸法の低品質画像を表示します。その画像が表示された直後に、低品質画像を高品質画像に差し替えるプロセスを開始します。
非常に基本的な実装は次のようになります。まず、画像にloadイベントリスナーを追加します。画像が読み込まれると、その同じイベントリスナーが自身をデタッチし、画像のsrcが最終的な高品質の画像に差し替えられます。
<img
width="100"
height="100"
alt="some alt text"
src="lq.webp"
onload="this.onload=null;this.src='hq.webp'"
> ステージ1: 低品質WebP 3-5kb 
ステージ2: 高品質WebP 20-40kb 
これは十分にシンプルに見えるかもしれませんし(実際にそうです)、レンダリングプロセスの初期段階で大量の画像を差し替えると、メインスレッドでのアクティビティが増えすぎて他のCore Web Vitalsの指標に影響を与えます。
そのため、私は作業の一部をWeb Workerにオフロードすることを選択しました。Web Workerは新しいスレッドで実行され、現在のページへの直接のアクセス権を持ちません。Web Workerとページ間の通信は、メッセージングシステムを通じて行われます。明らかな利点は、ページのメインスレッド自体を使用しないことです。これによりメインスレッドのリソースを解放できます。欠点は、Web Workerの使用が少し煩雑になる可能性があることです。
プロセス自体はそれほど難しくありません。DOMContentLoadedイベントが発生したら、ページ上のすべての画像を収集します。画像が読み込まれている場合は、直ちに差し替えます。画像が読み込まれていない場合(画像が遅延読み込みされる可能性があるため)、遅延読み込み後に画像を差し替えるイベントリスナーをアタッチします。
重要な注意点の1つ: ブラウザは、画像の差し替えのたびに新しいLCP候補として扱います。高品質な画像の差し替えが2.5秒後に発生した場合、LCPはプレースホルダーの時間ではなく、差し替えの時間で測定されます。そのため、Web Workerが画像を可能な限り速くフェッチして差し替えることが重要なのです。
結果: 素晴らしいものでした。

Web Workerを使用した2段階のLCP読み込みのコード
2段階の読み込みとWeb Workerを通じてLCPを高速化するために私が使用しているコードは以下の通りです。メインページのコードがWeb Workerを呼び出し、Web Workerが画像をフェッチします。Web Workerは結果をblobとしてメインページに渡します。blobを受信すると画像が差し替えられます。
Worker.js
self.addEventListener('message', async event => {
const newimageURL = event.data.src.replace("/lq-","/resize-");
const response = await fetch(newimageURL)
const blob = await response.blob()
// Send the image data to the UI thread!
self.postMessage({
uid: event.data.uid,
blob: blob,
})
}) Script.js
script.jsは、アクティブなウェブページ上で通常のスクリプトとして実行されます。スクリプトは最初にWorkerを読み込みます。次に、ページ上のすべての画像を順番に処理します。これはレンダリングプロセスの初期段階で行われます。画像はすでに読み込まれている場合もあれば、そうでない場合もあります。低品質の画像がすでに読み込まれている場合は、直ちに差し替えプロセスを呼び出します。まだ読み込まれていない場合は、画像のloadイベントにリスナーをアタッチし、その画像が読み込まれるとすぐに差し替えプロセスを開始するようにします。
画像が読み込まれると、その画像に対して一意のIDが生成されます。これにより、ページ上で画像を簡単に見つけることができます(前述の通り、WorkerはDOMにアクセスできないため、画像のDOMノードを送信することはできません)。画像URLと一意のIDはその後Workerに送信されます。Workerが画像をフェッチすると、blobとしてスクリプトに送り返されます。最終的に、スクリプトは古い画像URLを、Web Workerによって作成されたblob URLに差し替えます。
var myWorker = new Worker('/path-to/worker.js');
// send a message to worker
const sendMessage = (img) => {
// uid makes it easier to find the image
var uid = create_UID();
// set data-uid on image element
img.dataset.uid = uid;
// send message to worker
myWorker.postMessage({ src: img.src, uid: uid });
};
// generate the uid
const create_UID = () => {
var dt = new Date().getTime();
var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = (new Date().getTime() + Math.random() * 16) % 16 | 0;
dt = Math.floor(dt / 16);
return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
return uid;
}
// when we get a result from the worker
myWorker.addEventListener('message', event => {
// Grab the message data from the event
const imageData = event.data
// Get the original element for this image
const imageElement = document.querySelectorAll("img[data-uid='" + imageData.uid + "']");
// We can use the `Blob` as an image source! We just need to convert it
// to an object URL first
const objectURL = URL.createObjectURL(imageData.blob)
// Once the image is loaded, we'll want to do some extra cleanup
imageElement.onload = () => {
URL.revokeObjectURL(objectURL)
}
imageElement[0].setAttribute('src', objectURL)
})
// get all images
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('img[loading="lazy"]').forEach(
img => {
// image is already visible?
img.complete ?
// swap immediately
sendMessage(img) :
// swap on load
img.addEventListener(
"load", i => { sendMessage(img) }, { once: true }
)
})
}) フィールドでのLCPの改善を検証するには、Real User Monitoringを使用して、実際の訪問者がページをどのように体験しているかを追跡します。Lighthouseのようなラボツールでも改善は示されますが、Core Web Vitalsの基準を満たすために重要なのは、さまざまな接続環境にいる実際のユーザーからのフィールドデータです。

