CSS

CSSファイルの読込の高速化

2023年5月8日
2026年9月28日 改訂
著者: 竹洞 陽一郎

主なポイント

  1. CSS Background Image、@import、@font-faceで指定したリソースは、CSSファイルの中に書かれているためPreload Scannerから見えず、CSSのダウンロードとパースが終わるまでリクエストが始まらず、その発見の遅れがそのまま表示の遅れになります。
  2. CSS Background Imageへの対処が必要なのは、LCPの対象になる画像(多くのサイトではトップページのヒーロー画像1枚)だけで、<img>要素に変えるか、変えられない場合は<link rel="preload" as="image" fetchpriority="high">で先に発見させます。
  3. @importは使わずにHTMLに複数の<link rel="stylesheet">を書き、表示に必須のWebフォント(多くの場合1〜2ファイル)だけを<link rel="preload" as="font" crossorigin>で先読みします(crossorigin属性がないと、同じフォントを二重に読み込みます)。
  4. CSSを分割しても統合しても転送するデータの量は変わりませんが、画面に適用されるCSSは全て揃うまで描画をブロックするため、表示開始の速さは、描画をブロックするCSSの合計量、CSSファイルを発見するタイミング、ファイルの数で決まります。
  5. 光回線・HTTP/3での計測では、CSSを1ファイルに統合した場合と比べて、<link rel="stylesheet">で4本に分けるとFCPが約15%、1つの親CSSに@importを書くと約18%、@importを入れ子にすると約33%遅くなり、差が最も出にくい条件なので、これらは差の下限と考えられます。

はじめに

今回から、複数回にわたり、CSSに関する解説を連載していきます。

前回の記事では、Preload Scannerについて説明しました。
Preload Scannerは、Shallow Parsingを用いて、読み込むべきファイルを検出し、バックグラウンドでファイルの読み込みを行います。
このプロセスには、CSSも含まれています。

しかし、Preload Scannerから見えない場所に書かれたリソースは、発見が遅れ、読込が遅延してしまうのです。

本記事では、CSSの読込が遅くなる典型的なパターンとして、CSSから参照される画像とWebフォント、@import、CSSファイルの分割と統合を取り上げます。
クリティカルCSSのインライン化や、未使用CSSの削減は、本記事では扱いません。

CSSの読込とCSS Object Model

CSS Background Imageによる遅延

CSS Background Imageとは、CSS(Cascading Style Sheets)の機能の一つで、HTML要素の背景に画像を設定する際に使用されるスタイルプロパティです。
このプロパティを用いることで、HTML要素に対して画像を背景として表示し、デザインを豊かにすることができます。
CSS Background Imageは、以下のような形式で記述されます。


selector {
  background-image: url("path/to/image.jpg");
}

ここで、selectorは対象となるHTML要素を指定し、url("path/to/image.jpg")には表示したい画像ファイルへのパスが入ります。

CSS Background Imageの問題点

Webパフォーマンスにおいて、CSS Background Imageは、2008年に発売されたスティーブ・サウダーズ氏が書いた「ハイパフォーマンスWebサイト ―高速サイトを実現する14のルール」(オライリージャパン)で一躍注目を集めました。
CSSスプライトを利用することで、画像を統合して配信して、画像を一気に送るという手法です。

しかし、この本が出版された2008年から、ブラウザの仕組みは大きく変わりました。
<img>要素では、decoding="async"によるデコードの非同期化や、loading="lazy"による遅延読み込みを指定できるようになりました。

decoding="async"を提唱したAppleのSimon Fraser氏は、2016年10月18日にGitHub上で、大きな画像のデコードがメインスレッドを止める問題を指摘し、<img>要素に非同期デコードの指定を追加することを提案しました。
この提案の中で、Fraser氏は、CSSの画像には対応していないことも問題点として挙げています。
現在も、CSSの画像には、<img>要素のdecoding、loading、fetchpriorityに相当する指定がありません。
ただし、CSS Background Imageが遅くなる原因は、デコードではありません。
原因は、画像の発見の遅れと、スタイル計算の中でのリクエスト処理の2つです。

原因1:発見の遅れ

CSS Background Imageで指定した画像は、一般的なブラウザの実装では、次の順序で読み込まれます。

  1. ブラウザがHTMLを読み込み、Preload ScannerがCSSファイルを発見して、読み込みを開始する
  2. CSSファイルのダウンロードが終わり、パースされる
  3. スタイルの計算で、background-imageを指定したルールが要素に適用される
  4. ここで初めて、画像のリクエストが始まる

<img>要素であれば、Preload ScannerがHTMLを読んだ時点で画像のURLを発見できるので、CSSのダウンロードを待たずに画像の読み込みを開始できます。
CSS Background Imageでは、CSSのダウンロードとパースが終わるまで、画像のURLが分かりません。
CSS Object Modelの構築が遅れるわけではありませんが、その画像が表示されるまでの時間は、CSSの読込時間の分だけ遅れます。
ヒーロー画像のように、LCP(Largest Contentful Paint)の対象になる画像をCSS Background Imageで指定すると、この遅れがそのままLCPの遅れになります。

原因2:スタイル計算の中でのリクエスト処理

Chromiumのソースコードでは、CSS Background Imageの画像のリクエストは、スタイル計算(Chrome Developer Toolsの「Recalculate Style」)の最後に、メインスレッドで作られます。
要素のスタイルを計算し終えると、StyleResolver::ResolveStyle()からLoadPendingResources()が呼ばれ、ElementStyleResources::LoadPendingImages()を経て、ImageResourceContent::Fetch()で画像のリクエストが作られます。
リクエストを作る処理には、URLの解決、メモリキャッシュの確認、リクエストの準備などが含まれます。
1つあたりは小さな処理ですが、背景画像の数が多いと積み重なり、スタイル計算が長くなります。
スタイル計算が終わるまで、レイアウトと描画は始まりません。

同じ画像を指定したルールを多くの要素に適用しても、リクエストを作る処理は1回です。
処理が増えるのは、異なる背景画像の数に応じてです。
また、画像をdata: URLでCSSに埋め込んでいる場合は、このスタイル計算の中で、画像データの展開までメインスレッドで同期的に行われます。

<img>要素の画像は、HTMLの解析とPreload Scannerの段階でリクエストされるため、この処理はスタイル計算に入りません。

<img>要素で得られるもの

<img>要素を使うと、早く発見されること以外にも、次の機能が使えます。

現場で対処するのは、LCPの対象になる画像だけ

ただし、CSS Background Imageを全て<img>要素に置き換える必要はありません。
対処が必要なのは、LCPの対象になる画像だけです。
多くのサイトでは、トップページのヒーロー画像の1枚です。
その画像を<img>要素に変えるか、変えられない場合は、HTMLの<head>に次の1行を追加します。

<link rel="preload" as="image" href="画像のURL" fetchpriority="high">

装飾やパターンの背景画像は、CSS Background Imageのままで構いません。
ただし、異なる背景画像を数多く使っているページでは、スタイル計算が長くなります。
内容として意味のある画像から<img>要素に変えると、スタイル計算が軽くなります。

CSSの@importによる遅延

CSSの@importルールは、Preload Scanner(事前読み込みスキャナ)の恩恵を受けられません。
Preload ScannerはHTML内の要素を解析し、リソースの読み込みを事前に開始することが目的です。

Preload ScannerはHTMLファイル内の<link>要素や<script>要素などをスキャンしてリソースの読み込みを早めることができますが、@importルールはCSSファイル内に記述されるため、直接スキャンできません。

そのため、@importで読み込むCSSファイルは、@importを書いた親のCSSファイルのダウンロードとパースが終わるまで、リクエストが始まりません。
1つの親CSSに複数の@importを書いた場合、子のCSSファイルは親の読み込み後に並列で読み込まれますが、親の読み込み時間の分だけ開始が遅れます。
@importを入れ子にすると(子のCSSファイルの中にさらに@importを書くと)、入れ子の段数の分だけ読み込みが直列になります。
HTMLの<head>内で読み込む、画面に適用されるCSSは、全て揃うまで描画をブロックするので、この遅れがそのまま表示開始の遅れになります。

この問題を回避するためには、@importルールの代わりに、HTMLファイル内に複数の<link rel="stylesheet">要素を配置し、Preload Scannerが全てのCSSファイルを最初から発見できるようにします。

@importを1つの親CSSに書いた場合、@importを入れ子にした場合、link要素を4つ書いた場合の、CSSファイルの読込開始のタイミングの比較
@importと<link rel="stylesheet">による、CSSファイルの読込開始の違い(仕組みを示す模式図で、実測値ではありません)

@font-faceによる遅延

CSSの@font-faceで指定するWebフォントも、CSS Background Imageと同じく、CSSファイルの中にURLが書かれるため、Preload Scannerからは見えません。
さらに、一般的なブラウザの実装では、そのフォントを使う文字がページにあると分かった時点で、初めてフォントファイルのリクエストが始まります。

表示に必須のWebフォント(多くの場合、1〜2ファイル)だけを、次のように<link rel="preload">で、Preload Scannerに発見させます。

<link rel="preload" as="font" type="font/woff2" href="フォントのURL" crossorigin>

フォントは、同一オリジンから読み込む場合でもCORSモードで取得されるため、crossorigin属性が必要です。
crossorigin属性がないと、先読みしたフォントが使われず、同じフォントを二重に読み込むことになります。
全てのウェイトをpreloadすると、LCPの対象になる画像やCSSと帯域を奪い合うため、かえって遅くなることがあります。

CSSファイルの統合が速度に与える影響について

図を参照して、「複数のCSSファイルを1つに統合すれば、さらに高速化されるのでは?」と疑問に思う方もいるでしょう。
例えば、以下の4つのCSSファイルを統合する場合を考えてみましょう。

この場合、統合によってファイル容量が変わるでしょうか?
答えは「いいえ」です。
統合されたファイルの容量は、4つのファイルの合計値、つまり 50KB + 40KB + 30KB + 20KB = 140KBとなります。
gzipやBrotliで圧縮して配信する場合は、1つの大きなファイルの方が、圧縮率が少し上がることがあります。

それでは、転送はどうでしょうか。
ブラウザやプロトコルによって違いますが、Chromeの場合、同時転送の仕組みは以下の通りです。

HTTP/2とHTTP/3で同時に開けるストリーム数の上限は、サーバーが通知する値で決まります。
HTTP/2の仕様(RFC 9113 Section 6.5.2)では、この値を100より小さくしないことが推奨されています。

HTTP/2とHTTP/3の違いは、ストリームの数ではなく、パケットロスが起きたときの挙動です。
HTTP/2はTCPの上で動くため、1つのパケットロスで、そのコネクション上の全てのストリームの配送が止まります。
HTTP/3(QUIC)は、ストリームごとに再送と順序制御を行うため、他のストリームの配送は止まりません。
そのため、HTTP/2とHTTP/3で差が出るかどうかは、回線のパケットロス率や遅延によって変わります。

それぞれのプロトコルで、ファイルがどのように割り当てられて転送されるかを、表にまとめます。

HTTPプロトコルの各バージョンにおける同時転送の仕組みとファイルの割り当て方
プロトコルLayer4同時転送の仕組みと制約ファイルの割り当て方
HTTP/1.1 TCP 1つのコネクションで同時に転送できるのは1つのレスポンスだけです。
Chromeは同一ホストに最大6つのコネクションを張るため、同時に転送できるのは最大6ファイルです。
一度に送れる量は、MTU(1パケットの最大サイズ)ではなく、コネクションごとのTCPの輻輳ウィンドウと受信ウィンドウで決まります。
リソースの種類や優先順位に応じて、6つのコネクションに割り当てられます。
HTTP/2 TCP 1つのTCPコネクション上で複数のストリームを多重化し、複数のファイルのフレームを混在させて送ります。
一度に送れる量は、TCPの輻輳ウィンドウと、HTTP/2のフロー制御(ストリーム単位とコネクション単位。RFC 9113 Section 5.2)で決まります。
TCPでパケットロスが起きると、そのコネクション上の全てのストリームの配送が再送を待って止まります(TCPのHead-of-Line Blocking)。
1つのリクエストとレスポンスに1つのストリームを割り当てます。
ストリームIDは再利用できません(RFC 9113 Section 5.1.1)。
同時に開けるストリーム数はSETTINGS_MAX_CONCURRENT_STREAMS(RFC 9113 Section 6.5.2)で制限され、上限に達した場合は、既存のストリームが閉じるのを待ってから新しいストリームを開きます。
HTTP/3 UDP(QUIC) 1つのQUICコネクション上で複数のストリームを多重化します。
QUICのパケットも、経路のMTU(PMTU)を超える大きさでは送らず、IPフラグメンテーションを起こさないように設計されています(RFC 9000 Section 14)。
一度に送れる量は、QUICの輻輳制御(RFC 9002)とフロー制御で決まります。
再送と順序制御はQUICがストリームごとに行うため、あるストリームのパケットロスが、他のストリームの配送を止めません。
1つのリクエストとレスポンスに1つのストリームを割り当てます。

描画をブロックするCSSは、全て揃うまで待たされる

HTMLの<head>内で読み込む、画面に適用されるCSSは、全てのCSSファイルが揃うまで描画をブロックします。
(media="print"を指定した印刷用のCSSのように、media属性が画面に合わないCSSは、描画をブロックしません。)
そのため、CSSを4つに分割しても、1つに統合しても、140KBのCSSが全て揃うまでは描画が始まりません。
表示開始の速さは、描画をブロックするCSSの合計量、CSSファイルを発見するタイミング、そしてファイルの数で決まります。
後述する計測結果のとおり、合計量が同じでも、4本に分けると1本にまとめた場合より表示開始が遅くなりました。

現場での判断

CSSファイルの本数は、CMSやテーマ、ビルドツールの既定で決まることが多く、現場で細かく調整するのは難しいのが実情です。
それでも、後述する計測結果のとおり、最も条件の良い環境でも、分割の本数と@importの段数の分だけ表示開始は遅くなります。
現場で優先して行うのは、次の2つです。

ただし、ページごとに使わないCSSまで1つにまとめると、描画をブロックするCSSの合計量が増えます。
まとめた後も、実測で確認してください。

CSSファイルのダウンロードを挽肉づくりに例える

ファイル転送は、挽肉づくりに似ています。
大きな肉の塊でも、小さい肉の塊でも、ミートグラインダーに入れれば、均等に挽かれて出てきます。

挽肉は、肉の粒をくっつけても元の肉の塊には戻らないですが、ファイル転送の場合は、パケット単位で送られたデータが結合されて元のファイルと同じになります。

ひき肉づくり

挽肉をつくる機械「ミートグラインダー」(肉挽き機)に1.4kgの肉の塊を入れても、500g、400g、300g、200gの合計1.4kgの肉の塊を入れても、結果として1.4kgの挽肉が出来上がります。
同様に、結合して140KBのファイルを送っても、分割された50KB + 40KB + 30KB + 20KB =合計140KBのファイルを送っても、送られるデータの量は同じです。

ただし、ファイル転送では、挽く量が同じでも、肉をいつ機械に入れられるか、どの肉から先に挽くかで、出来上がるまでの時間が変わります。
HTTP/1.1は6台のミートグラインダーを並べて使い、HTTP/2とHTTP/3は1台のミートグラインダーに複数の肉を少しずつ交互に入れて挽く、というイメージです。
HTTP/2では、1か所で肉が詰まると機械全体が止まりますが、HTTP/3では、詰まった肉以外は挽き続けられます。
そして、@importやCSS Background Imageは、肉を機械に入れるタイミングそのものを遅らせます。

「Webパフォーマンスチューニングで不可欠なTOC(制約条件の理論)を学ぼう」で、制約条件について解説しています。
CSSの読込時間は、適切に対処していれば、制約にならない、ということです。

後述する計測では、@importを入れ子にしたことによる遅れは、CSSを4本に分けたことによる遅れと同じくらいの大きさでした。
CSS Background Imageや@font-faceによる発見の遅れも含めて、まず発見の遅れを解消し、そのうえでファイルの本数を減らすのが効果的です。

計測結果

本記事の内容を確かめるため、CSSの読み込み方が異なる4つのサンプルページを用意し、表示開始(FCP)までの時間を計測しました。

CSSの読み込み方による、表示開始(FCP)までの時間(各5回の中央値)
読み込み方最後のCSSの読み込み完了FCP1ファイルに統合した場合との差
1つのCSSファイルに統合約29.7ms約50.7ms―
<link rel="stylesheet">を4つ書く約34.7ms約58.4ms+7.7ms(+15%)
1つの親CSSに@importを書く約37.3ms約59.7ms+9.0ms(+18%)
@importを入れ子にする約43.2ms約67.2ms+16.5ms(+33%)

CSSを1ファイルに統合したページが、最も早く表示されました。
<link rel="stylesheet">で4本に分けるとFCPが約15%、1つの親CSSに@importを書くと約18%、@importを入れ子にすると約33%遅くなりました。
@importを入れ子にしたページでは、前のCSSが届いてから次のCSSが要求される階段状の読み込みが、5回とも確認できました。

今回の計測は、光回線で往復時間が短く、CSSも4本だけという、差が最も出にくい条件です。
それでもこれだけの差が出ているので、ここで示した値は差の下限と考えてください。
@importによる遅れは往復時間に比例して、分割による遅れはファイルの数に応じて大きくなる仕組みなので、モバイル回線やCSSファイルの多いサイトでは、差はさらに広がると考えられます。

まとめ

CSSの読込が遅くなる主な原因は、Preload Scannerからリソースが見えないことによる発見の遅れ、CSS Background Imageのリクエストがスタイル計算の中で処理されること、そしてCSSファイルの分割です。

CSSスプライトは、画像を1枚にまとめてリクエスト数を減らす手法でしたが、HTTP/2以降では、リクエスト数を減らす利点は小さくなっています。
また、CSSスプライトはCSS Background Imageとして読み込むため、発見が遅れるという問題も抱えています。