ファイル名によるCSSのバージョン管理

CSSやJavaScriptのクエリ文字列でのバージョン付けを見直そう

2026年9月29日
著者: 竹洞 陽一郎

主なポイント

  1. CSSやJavaScriptの更新を確実に反映させるには、ファイル名そのものにバージョン(ビルドの仕組みがあればハッシュ値、なければ日付)を入れて、URLを変えることが有効です。
  2. WordPressの ?ver= のようなクエリ文字列方式は、CDNがクエリ文字列をキャッシュキーに含めない設定になっていると機能しないため、WordPress本体とプラグインについては、CDNで少なくとも ver をキャッシュキーに含める必要があります。
  3. キャッシュの有効期限は、オリジンサーバーのCache-Controlで指定し、ファイル名にバージョンを入れたファイルは1年間、HTMLとバージョンのないファイルはno-cacheか短い有効期限にします。
  4. ファイル名方式が効くのは、参照元のHTMLが新しいファイル名を出力している場合だけなので、HTMLのキャッシュの設定も合わせて確認することが重要です。
  5. 「反映されない」ときは、推測する前に、Chrome Developer Toolsとcurlで、読み込まれているファイルとレスポンスヘッダーを確認することで、関係者のあいだで原因の確認を往復させずに済みます。

大前提:キャッシュはオリジンサーバーがCache-Controlヘッダーで制御する

本題に入る前に、大前提を確認しておきます。
キャッシュの制御は、原則としてオリジンサーバーが行います。
オリジンサーバーとは、CDNの背後にある、Webサイト本来のサーバーのことです(WordPressのサイトであれば、WordPressが動いているサーバーです)。
オリジンサーバーが、ファイルごとにCache-Controlヘッダーで「どれだけの期間、キャッシュしてよいか」「使う前に確認が必要か」を宣言し、CDNとブラウザはその宣言に従う、という構造です。

これはHTTPの仕様(RFC 9111)が定めている構造そのものです。
RFC 9111のSection 4.2では、キャッシュの有効期限を決める主な仕組みは、オリジンサーバーが明示的な有効期限を示すことだとされています(条文は後述します)。
CDNの管理画面でキャッシュの有効期限を設定することもできます。
RFC 9111が鮮度の主な仕組みとしているのはオリジンが明示的な有効期限を示すことなので、この記事ではオリジンでの設定を原則とします。
ただし、実際には、共用ホスティングや他社が管理するWordPressのように、オリジンの設定を変えられない環境も少なくありません。
その場合の優先順位は、次のとおりです。

  1. オリジンでCache-Controlを設定できるなら、オリジンで設定する(ブラウザ向けは max-age、CDN向けは s-maxage で分けられます)
  2. オリジンを変えられないなら、CDNの設定(キャッシュルールやTTLの上書き)で補う。ただし、ブラウザに届くCache-Controlまで書き換える設定かどうかを確認する
  3. どちらもできないなら、少なくともファイル名にバージョンを入れて、更新時にURLが変わるようにする

max-age と s-maxage の使い分けと、主要なCDNがs-maxageをどう扱うかは、max-ageは何秒にすべきかで説明しています。

CDN向けの指定を、Cache-Controlとは別のヘッダーで送る方法もあります。
RFC 9213(Targeted HTTP Cache Control、2022年6月、Proposed Standard)は、CDNのキャッシュだけを対象とするCDN-Cache-Controlヘッダーを定義しています。
ディレクティブの意味と優先順位は、Cache-Controlと同じです。
RFC 9213のSection 2.2では、この仕組みに対応したキャッシュは、有効な値を持つCDN-Cache-Controlがあれば、そのレスポンスのCache-ControlとExpiresを無視しなければならない(MUST)とされています。
例えば Cache-Control: max-age=600 と CDN-Cache-Control: max-age=3600 を併せて返すと、対応したCDNはCache-Controlを見ずに3600秒を、それ以外のキャッシュ(ブラウザなど)はCache-Controlの600秒を、有効期限として使います。
CDNが独自のヘッダー(CDNの名前を付けた「〜-Cache-Control」など)にも対応している場合、どれを優先するかはCDNごとに決まっているので、そのCDNのドキュメントで確認してください。

CDNにだけ長い有効期限を与える場合は、Ageヘッダーにも注意が必要です。
RFC 9213のSection 2.3では、上の例と同じ組み合わせのレスポンスがCDNに30分(Age: 1800)保持されていると、CDNから見ればまだ有効期限内でも、他のキャッシュから見ればすでに有効期限切れになる、と説明されています。
つまり、ブラウザを含む他のキャッシュは、受け取った時点で有効期限切れのレスポンスとして扱い、次に使う前に確認することになり得ます。
このときの確認先は、多くの場合、オリジンサーバーではなくCDNです。
RFC 9213は、この影響を減らす運用の例として、Ageヘッダーを削除する、Dateヘッダーを現在時刻に更新する、などを挙げていますが、実装に対する要件は定めていません。

CDN-Cache-Controlも、オリジンサーバーが宣言する仕組みなので、この記事の原則と矛盾しません。
ただし、対応しているかどうかはCDNによって異なるので、使う前に、利用しているCDNの公式ドキュメントで確認してください。

逆に言うと、オリジンサーバーがCache-Controlヘッダーを返していないWebサイトは、キャッシュの制御をしていないということです。
その場合、CDNとブラウザは、それぞれ独自の判断でキャッシュの有効期限を決めます。
「CSSを変えたのに反映されない」という問題の多くは、この「誰も制御していない」状態から生まれます。

この記事では、この大前提に基づいて、CSSやJavaScriptの更新を確実に反映させるための設計を説明します。

Cache-Controlの主な相手は、今ではブラウザよりCDN

Cache-Controlヘッダーは、もともとブラウザのキャッシュを制御するものとして広まりました。
しかし現代では、ブラウザのキャッシュよりも、CDNのキャッシュを制御するための存在意義のほうが大きくなっていると、私は考えています。
理由は、ブラウザのキャッシュが、特にモバイルでは、期待するほど長くリソースを保持してくれないからです。

ブラウザのキャッシュは、そのユーザーが訪問するすべてのサイトのリソースを、限られた容量の中で共有しており、容量を超えると古いものから追い出されます。
Facebookが2015年に公開した実測調査では、平均44.6%のユーザーが空のキャッシュでアクセスしており、モバイルでは半数のリクエストが12時間以内のキャッシュしか持っていませんでした。
この点は、別の記事「ブラウザキャッシュは何日残るのか」で、RFC 9111の規定、Chrome・Firefox・Safariの実装、実測データを基に詳しく説明しています。

一方、CDNのキャッシュは、数千のエッジサーバー(ユーザーの近くに配置され、CDNのキャッシュを保持するサーバー)に配置され、容量もブラウザとは比較になりません。
CDNのエッジサーバーにリソースが保持されていれば、ブラウザのキャッシュが消えていても、ユーザーは最寄りのエッジサーバーから即座に取得できます。
Cache-Controlヘッダーで有効期限を正しく指定することの実質的な効果は、ブラウザに長く残すことよりも、CDNのエッジサーバーに長く残すことにあります。
そして、CDNのキャッシュを制御する手段もまた、オリジンサーバーが返すCache-Controlヘッダーなのです。

CDNを導入したお客様からよくいただく相談

CDNを導入しているお客様から、よくいただく相談があります。
「CSSを変更したのに、サイトに反映されない。CDNのキャッシュが原因ではないか」という相談です。

先日も、あるWordPressサイトで同じ相談を受けました。
制作会社がCSSを変更したものの反映されず、お客様がCDNのキャッシュを全削除(Purge。CDNに保持されているキャッシュを管理画面やAPIから削除する操作)しても解決しない、という状況でした。
この記事では、このサイトを「先日のサイト」と呼び、確認した内容を例として引用します。

実際に調べてみると、CDNのキャッシュは原因ではありませんでした。
「反映されない」の直接の原因は、変更したはずのCSSファイルの更新日時(Last-Modified)が変更日より前のままだったことと、読み込み先のパスの誤りでした。
どちらも、CDNとは関係のない原因です。

ただし、調べる過程で、次のことも分かりました。

直接の原因を直せば、今回の「反映されない」は解消します。
しかし、CSSのURLの付け方と、キャッシュの有効期限を誰が決めるのかという、2つの設計がこのままでは、次に更新したときに同じ混乱が起きます。
この記事では、その2つの設計について、HTTPの仕様であるRFC 9111を基に解説します。
直接の原因をどのように確認したかは、推測せずに、実測で確かめるで説明します。

キャッシュバスティングとは

CSSやJavaScript、画像などのファイルは、ブラウザやCDNにキャッシュされます。
キャッシュされたファイルは、有効期限が切れるまで再利用されるため、表示が速くなります。

一方で、ファイルの内容を更新しても、キャッシュが残っている間は古いファイルが使われてしまいます。
そこで、ファイルを更新したときにURLを変えて、ブラウザやCDNに「別のファイル」として取得させる手法が使われます。
これをキャッシュバスティング(Cache Busting)と云います。

URLの変え方には、大きく2つの方法があります。

クエリ文字列方式
style.css?ver=1.2 のように、URLの末尾にバージョンを付ける方法
ファイル名方式
style.1.2.css や style.x234dff.css のように、ファイル名そのものにバージョンやハッシュ値を入れる方法

WordPressは、標準でクエリ文字列方式を使っています。
そのため、WordPressのサイトでは、ほとんどのCSSとJavaScriptに ?ver= が付いています。

WordPressの?ver=の落とし穴

WordPressでCSSを読み込む関数wp_enqueue_styleには、バージョンを指定する引数があります。
この引数を省略すると、WordPress本体のバージョンが自動で付きます。

WordPress本体のソースコード(wp-includes/class-wp-styles.phpの_css_hrefメソッド)を見ると、この動作が分かります。


if ( empty( $ver ) && null !== $ver && is_string( $this->default_version ) ) {
    $ver_to_add = $this->default_version;
} elseif ( is_scalar( $ver ) ) {
    $ver_to_add = (string) $ver;
}

バージョン引数が空で、nullでもない場合(省略時の既定値falseなど)は、既定のバージョン、つまりWordPress本体のバージョンが付きます。
先日のサイトでも、テーマのCSSはすべて ?ver=7.1.2 という、WordPress本体のバージョンが付いた状態で読み込まれていました。

この状態では、CSSファイルをいくら編集しても、URLは変わりません。
URLが変わるのは、WordPress本体を更新したときだけです。
クエリ文字列方式を使っているつもりでも、実際にはキャッシュバスティングが機能していないサイトは少なくありません。

クエリ文字列方式の問題

歴史的な経緯

クエリ文字列方式の問題は、最近になって言われ始めたことではありません。
Webパフォーマンスの分野を切り開いたSteve Souders氏(当時Yahoo!、後にGoogle)は、2008年に「Revving Filenames: don't use querystring」という記事を書いています。
mylogo.1.2.gif と mylogo.gif?v=1.2 のどちらがよいかという問いに対して、一般的なプロキシのキャッシュの恩恵を受けるには、クエリ文字列での更新を避け、ファイル名そのものを変えるべきだと結論づけています。
当時広く使われていたプロキシサーバーのSquidが、既定の設定ではクエリ文字列付きのURLをキャッシュしなかったためです。

Squidの既定の設定という理由そのものは、現在では当てはまりません。
しかし、「途中のキャッシュがクエリ文字列をどう扱うかは、サイト側では制御できない」という問題の構造は、18年前から変わっていないのです。

この背景には、当時のHTTPの仕様もありました。
現行のHTTPキャッシュの仕様であるRFC 9111のSection 4.2.2のNoteには、以前の仕様(RFC 2616のSection 13.9)では、クエリ文字列を含むURIについてキャッシュがヒューリスティックに(明示的な指定がないときに、経験則で)有効期限を計算することを禁止していたが、実際にはあまり実装されなかった、と書かれています。
現行のRFC 9111には、この禁止規定はありません。

現在の問題:CDNの設定に依存する

現在、クエリ文字列方式が問題になるのは、CDNの設定です。
多くのCDNには、クエリ文字列をキャッシュのキーから除外する設定があります。

例えばGcoreのCDNの「Query string」オプションには、Ignore All、Ignore All Except、Ignore onlyの3つのモードがあります。
Ignore Allは、クエリ文字列の値が違っても、同じファイルとしてキャッシュするモードです。
この設定になっていると、?ver= の値を変えても、CDNは古いファイルを返し続けます。

つまり、クエリ文字列方式が機能するかどうかは、CDNのキャッシュキーの設定次第ということです。
CDNによって既定値も違います。
Cloudflareは既定の設定(Standard)でクエリ文字列ごとに別のファイルとしてキャッシュしますが、Amazon CloudFrontの静的コンテンツ向けの管理ポリシー(CachingOptimized)は、クエリ文字列をキャッシュキーに含めません。
同じWordPressサイトでも、CDNを乗り換えただけで、?ver= が効いたり効かなかったりします。
なお、CloudFrontのCachingOptimizedは最小TTLが1秒で、オリジンが no-cache を返しても最低1秒はキャッシュし、オリジンがCache-Controlを返さない場合は既定の24時間キャッシュします。
バージョンの付いていない静的ファイルに、このポリシーをそのまま使うのは避けてください。
制作会社がバージョン番号を正しく管理していても、CDNの設定が変わっただけで、キャッシュバスティングが効かなくなります。
自分たちで制御できない要素への依存は、品質管理の観点では、できるだけ減らすべきです。

Googlebotはキャッシュヘッダーを無視することがある

SEOの観点からも、注意すべき点があります。
Google検索セントラルの「Fix Search-related JavaScript problems」には、Googlebotのキャッシュについて、次のように書かれています。

This may lead WRS to use outdated JavaScript or CSS resources.

この文の前では、Googlebotはネットワークリクエストとリソースの使用量を減らすために積極的にキャッシュを行い、レンダリングを担うWRS(Web Rendering Service)はキャッシュヘッダーを無視することがある、と説明されています。
その結果として、WRSが古いJavaScriptやCSSを使ってページをレンダリングする可能性がある、というのが引用した文の意味です。

Google検索セントラルは、この問題を避ける方法として、コンテンツのフィンガープリント(ファイルの内容から計算したハッシュ値)をファイル名の一部にする方法(例:main.2bb85551.js)を挙げています。
フィンガープリントはファイルの内容から生成されるため、内容を更新するたびに異なるファイル名になります。
Google検索セントラルブログの「Crawling December: The how and why of Googlebot crawling」(2024年12月3日)では、WRSのキャッシュの有効期限はHTTPのキャッシュの指定に影響されず、WRSは最大30日間キャッシュする、とも説明されています。
Googlebotに更新後のCSSやJavaScriptを確実に使わせるには、キャッシュヘッダーだけに頼らず、更新時にURLそのものを変える必要があるということです。
Googleの説明で例に挙がっているのはファイル名にハッシュを入れる方法ですが、クエリ文字列を使ってはいけないとまでは書かれていません。

推奨する方法:ファイル名にバージョンを入れる

Google Chromeチームが運営するweb.devの「Prevent unnecessary network requests with the HTTP Cache」でも、同じ方法が推奨されています。
クライアント側のキャッシュと素早い更新の両方を得るには、内容が変わるたびにリソースのURLを変えて、ユーザーに新しいレスポンスを取得させるという説明の後に、次のように書かれています。

Typically, you do this by embedding a fingerprint of the file, or a version number, in its filename

通常は、ファイルのフィンガープリントかバージョン番号をファイル名に埋め込むことでこれを行う、という意味です。
web.devが例として挙げているファイル名は style.x234dff.css で、クエリ文字列ではありません。

同じ記事では、フィンガープリントやバージョン情報を含み、内容が変わることのないURLに対しては、Cache-Control: max-age=31536000(1年間)を付けることも推奨されています。
また、webpackのようなビルドツールを使えば、ハッシュ値のフィンガープリントをURLに付ける処理を自動化できるとも書かれています。

ファイル名方式には、次の利点があります。

ViteやwebpackなどのビルドツールでWebサイトを構築している場合は、ファイル名へのハッシュ値の付与が標準の機能として用意されています。
ビルドツールを使わないサイトでも、base.20260925.css のように日付をファイル名に入れれば、更新時にURLを変えられます。

ただし、優先すべきなのはハッシュ値です。
ハッシュ値はファイルの内容から計算されるので、内容が変わったときにだけURLが変わり、内容が同じならURLも変わりません。
日付は人が付けるので、付け忘れや、同じ日に2回更新したときの衝突が起き、内容を変えていないのに名前だけを変えることもあります。
日付は、ビルドの仕組みがない場合の最低限の方法と考えてください。
Google検索セントラルブログの「Crawling December: The how and why of Googlebot crawling」(2024年12月3日)でも、キャッシュバスティングは慎重に使うべきで、リソースのURLが変わると、内容が変わっていなくても、Googleが再クロールする必要が生じ得る、と説明されています。
内容が変わったときにだけURLが変わるハッシュ値は、この点でも有利です。

ただし、ファイル名方式が効くのは、そのファイルを参照しているHTMLが、新しいファイル名を出力している場合だけです。
HTMLがCDNやページキャッシュのプラグインに長く保持されていると、古いファイル名を指すlink要素やscript要素が配信され続け、CSSのファイル名を変えても、古いCSSが読み込まれ続けます。
そのため、ファイル名方式では、次のようにファイルの種類ごとにキャッシュの方針を分けます。

ファイル名方式でのキャッシュの方針
対象 URL Cache-Controlの方針
ファイル名にバージョンを入れたCSS、JavaScript、画像、フォント 内容が変わるとURLが変わる 長期間(1年など)キャッシュする
HTMLと、ファイル名にバージョンを入れていないファイル 内容が変わってもURLは変わらない 毎回確認する(no-cache)か、短い有効期限にする

CSSそのもののPurgeは不要になりますが、HTMLについては、短い有効期限にするか、更新時にPurgeする運用が残ります。
特に、CDNやページキャッシュのプラグインの設定で、HTMLに長い有効期限を付けていると、この前提が崩れます。
ファイル名方式に切り替えるときは、HTMLに付いている有効期限も確認してください。
具体的な値は、ファイルの種類ごとの設定で説明します。

WordPressでファイル名方式にする場合

WordPressでファイル名方式にする場合は、wp_enqueue_styleのバージョン引数にnullを指定します。
前述のソースコードのとおり、nullを指定すると ?ver= は付きません。
JavaScriptを読み込むwp_enqueue_scriptも同じです。
wp-includes/class-wp-scripts.phpのdo_itemメソッドに、class-wp-styles.phpと同じ条件分岐(バージョン引数が空でnullでなければWordPress本体のバージョンを付ける)があることを、WordPress 7.1.2のソースコードで確認しました。


wp_enqueue_style( 'my-theme-base', get_stylesheet_directory_uri() . '/base.20260925.css', array(), null );
wp_enqueue_script( 'my-theme-main', get_stylesheet_directory_uri() . '/main.20260925.js', array(), null, true );

ただし、すべてのCSSやJavaScriptをファイル名方式にできるわけではありません。

そのため、制作会社が管理するCSSは子テーマに独自のファイルとして置き、そのファイルにファイル名方式を適用するのが現実的です。
子テーマのstyle.cssには、テーマの情報(ヘッダーのコメント)だけを残し、スタイルの本体は別のファイルに移します。

ファイル名を変える作業を、どこで行うかはサイトの作り方によって違います。

ビルドの仕組みがあるサイト
webpackやViteなどのビルドツールで、ファイル名にハッシュを付けて出力し、そのファイル名をテーマに渡します。
更新のたびに人がファイル名を変える必要がなく、付け忘れも起きません。
ビルドの仕組みがないサイト
ファイル名を手で変えるのは、付け忘れや、同じ日に2回更新したときの衝突が起きやすい運用です。
現実的な代替は、ファイルの更新時刻(filemtime)をバージョン引数に渡し、?ver= の値がファイルの更新に連動するようにすることです。
ただし、これはクエリ文字列方式のままなので、CDNがクエリ文字列をキャッシュキーに含める設定であることが前提になります。
クエリ文字列をファイル名に埋め込んで出力し、Webサーバーの内部リライトで元のファイルに対応付ける方法もありますが、サーバーの設定が必要です。

// ビルドの仕組みがない場合:ファイルの更新時刻をバージョンにする(クエリ文字列方式)
$path = get_stylesheet_directory() . '/base.css';
wp_enqueue_style( 'my-theme-base', get_stylesheet_directory_uri() . '/base.css', array(), filemtime( $path ) );

WordPress本体とプラグインのCSSやJavaScriptはクエリ文字列方式のまま残るので、ファイル名方式と混在することになります。
その場合、CDNではクエリ文字列(少なくとも ver)をキャッシュキーに含める設定にしておく必要があります。
つまり、WordPressでは、クエリ文字列方式への依存をなくすことはできません。
自分たちで管理するCSSやJavaScriptはファイル名方式にして依存を減らし、残るWordPress本体とプラグインについては、CDNのキャッシュキーの設定を、関係者のあいだの約束事として文書にしておくのが現実的です。
GcoreのCDNであれば、Query stringオプションをIgnore Allにしないことが条件で、他のクエリ文字列を無視したい場合は、Ignore All Exceptを選んで ver を指定します。
また、ファイル名方式にしたCSSの中で url() や @font-face、@import で参照している画像やフォントのURLが変わらないままだと、キャッシュバスティングは半分しか効きません。
参照先のファイルも、同じ規則でファイル名にバージョンを入れてください。

Cache-Controlはオリジンで決める

RFC 9111の考え方

冒頭の大前提に戻ります。
キャッシュの有効期限を決めるのはオリジンサーバーです。
RFC 9111のSection 4.2には、次のように書かれています。

The primary mechanism for determining freshness is for an origin server to provide an explicit expiration time in the future, using either the Expires header field (Section 5.3) or the max-age response directive (Section 5.2.2.1).

キャッシュの有効期限を決める主な仕組みは、オリジンサーバーがExpiresヘッダーまたはmax-ageディレクティブで、明示的な有効期限を示すことだという意味です。

また、同じくSection 4.2.2のNoteには、次のように書かれています。

Therefore, origin servers are encouraged to send explicit directives (e.g., Cache-Control: no-cache) if they wish to prevent caching.

キャッシュさせたくない場合は、オリジンサーバーが明示的なディレクティブを送ることが推奨される(encouraged)という意味です。
なお、この"encouraged"は、RFC 2119やRFC 8174で定義されるMUSTやSHOULDのような規範用語ではありません。

このように、HTTPの仕様は、オリジンサーバーがキャッシュの方針を示し、途中のキャッシュ(CDNやブラウザ)がそれに従うという構造になっています。
CDN側で有効期限を上書きする設定も多くのCDNに用意されていますが、それはオリジンの設定を変更できない場合などの例外的な手段です。

WordPressは標準ではCache-Controlヘッダーを付けない

WordPressのサイトでは、この大前提が満たされていないことがよくあります。
WordPress本体は、通常のページのHTMLに対して、標準ではCache-Controlヘッダーを付けないからです。

WordPress 7.1.2のソースコード(wp-includes/class-wp.phpのsend_headersメソッド)を見ると、Cache-Controlが付くのは次の場合だけです。


if ( is_user_logged_in() ) {
    $headers = array_merge( $headers, wp_get_nocache_headers() );
} elseif ( ! empty( $_GET['unapproved'] ) && ! empty( $_GET['moderation-hash'] ) ) {
    // 未承認コメントの閲覧時(10分間の有効期限)
    ...
}
if ( ! empty( $this->query_vars['error'] ) ) {
    $status = (int) $this->query_vars['error'];
    if ( 404 === $status ) {
        if ( ! is_user_logged_in() ) {
            $headers = array_merge( $headers, wp_get_nocache_headers() );
        }
        ...

つまり、ログイン中のユーザー、404ページ、未承認コメントの閲覧という、限られた場合にだけキャッシュを禁止するヘッダー(wp_get_nocache_headersが返すCache-Control: no-cache, must-revalidate, max-age=0, no-store, private)が付きます。
ログインしていない一般の訪問者が通常のページを見る場合、WordPress本体はContent-Typeを付けるだけで、Cache-ControlもLast-ModifiedもExpiresも付けません。

CSSや画像などの静的ファイルは、そもそもWordPress(PHP)を通らず、Webサーバーが直接返します。
Webサーバー側でCache-Controlの設定をしていなければ、これらにもCache-Controlは付きません。

したがって、キャッシュ系のプラグインを使っていないWordPressサイトは、HTMLも静的ファイルも、Cache-Controlヘッダーなしで配信されている可能性が高いです。
先日のサイトでは、HTMLについては以前に弊社が指摘し、制作会社がfunctions.phpでCache-Controlを追加していましたが、CSSには何も付いていませんでした。
HTMLに対応しただけでは、静的ファイルの問題は残るということです。
自分のサイトがCache-Controlを返しているかどうかは、思い込みで判断せず、後述する方法で実測してください。

Cache-Controlがないとどうなるか

先日のサイトでは、CSSのレスポンスにCache-ControlヘッダーもExpiresヘッダーも付いていませんでした。
この場合、2つのキャッシュが、それぞれ独自の判断で有効期限を決めます。

1つ目はCDNです。
このサイトのCDNはオリジンの指定に従う設定でしたが、オリジンが何も指定しない場合は、CDN側の既定値(このサイトでは4日間)でキャッシュしていました。

2つ目はブラウザです。
RFC 9111のSection 4.2.2では、明示的な有効期限がない場合、キャッシュがヒューリスティックに有効期限を決めてよい(MAY)とされています。
その方法として、Last-Modifiedヘッダーの日時からの経過時間の一定割合を使うことが推奨されており、典型的な値として10%が示されています。

Chromeは、この10%の方式を実装しています。
Chromiumのソースコード(net/http/http_response_headers.cc)のGetFreshnessLifetimesメソッドに、次の記述があります(2026年9月25日時点のmainブランチ)。


std::optional<Time> last_modified_value = GetLastModifiedValue();
if (last_modified_value) {
  // The last-modified value can be a date in the future!
  if (last_modified_value.value() <= date_value) {
    lifetimes.freshness = (date_value - last_modified_value.value()) / 10;
    return lifetimes;
  }
}

Dateヘッダーの日時からLast-Modifiedヘッダーの日時を引き、10で割った値が有効期限になっています。
このコードに到達するのは、Cache-Controlのmax-ageもExpiresもなく、ステータスコードが200、203、206のいずれかで、must-revalidateが指定されていない場合です。
Cache-Controlを付けていないCSSは、まさにこの条件に当てはまります。
なお、これは仕様ではなくChromeの実装上の挙動であり、他のブラウザの計算方法は異なる場合があります。

このサイトのCSSには、約5年半前のLast-Modifiedが付いていました。
その10%は約200日です。
つまり、Chromeは、キャッシュが残っていれば最長で約200日間、サーバーに確認せずに古いCSSを使ってよいと判断する状態でした。

これは「Cache-Controlがないと何が起こり得るか」を示す理論上の上限です。
ブラウザキャッシュは何日残るのかで説明したとおり、実際にはブラウザのキャッシュはもっと早く追い出されることが多いので、200日間必ず古いCSSが表示されるわけではありません。
また、先日のサイトで「反映されない」の直接の原因だったのは、冒頭で述べたとおり、ファイルの更新日時が変わっていなかったことと、読み込み先のパスの誤りでした。
ヘッダーの欠如は、それとは別に直すべき問題です。

ファイルの種類ごとの設定

なお、長期キャッシュの本体は max-age で、immutable は再読み込み時の再検証を省くための追加の指定です。
immutable に対応していないブラウザ(Chromiumは2026年9月時点で既定では無効)では単に無視されるので、付けても害はなく、付けなくても max-age の効果は変わりません。

ファイル名方式と組み合わせる場合、Cache-Controlの設定は次のようになります。

ファイル名にバージョンを入れたファイル
Cache-Control: max-age=31536000, immutable(1年間キャッシュし、有効期限内は再確認しない)
ファイル名にバージョンを入れていないファイル
Cache-Control: no-cache(毎回サーバーに更新の有無を確認する)

immutableは、RFC 8246(HTTP Immutable Responses、2017年9月、Proposed Standard)で定義されたCache-Controlの拡張ディレクティブです。
RFC 8246のSection 1では、まさにこの記事で説明している「バージョン付きURL」の設計パターンが、immutableを定義する動機として挙げられています。
リソースを更新するときは新しいURLで公開し、参照側も書き換えるという運用であれば、非常に長い有効期限を設定できる、という説明です。
一方で、ブラウザはそのURLがバージョン付きであることを知らないため、ユーザーが再読み込みをすると、すべてのサブリソースに対して条件付きリクエストが発生し、304応答が返るだけの無駄な通信になります。
immutableは、この無駄をなくすためのものです。

RFC 8246のSection 2には、次のように書かれています。

Clients SHOULD NOT issue a conditional request during the response's freshness lifetime

immutableが付いたレスポンスに対して、クライアントは有効期限内には条件付きリクエストを発行すべきでない(SHOULD NOT)、という意味です。
ユーザーによる明示的な指示(強制再読み込みなど)がある場合は例外です。
また、Section 3(Security Considerations)では、HTTPSのような認証されたコンテキストでないリソースについては、クライアントはimmutableを無視すべき(SHOULD)とされています。
immutableを使うなら、HTTPSでの配信が前提ということです。

対応状況はブラウザによって異なります。
Chromiumのソースコードには、immutableを解釈する実装(net/base/features.ccのkCacheControlImmutable)が入っていますが、2026年9月25日時点のmainブランチでは既定で無効(FEATURE_DISABLED_BY_DEFAULT)になっています。
web.devの前述の記事にも、immutableは一部のブラウザでは無視されると書かれています。
対応していないブラウザでも、max-ageの指定は有効です。

no-cacheは、「キャッシュしない」という意味ではなく、「使う前に毎回サーバーに確認する」という意味です。
ファイルが更新されていなければ、サーバーは本文を返さずに304 Not Modifiedだけを返すため、転送量はわずかです。
ただし、オリジンサーバーへのリクエスト数は増えます。
オリジンの負荷や応答速度が気になる場合は、max-age=300(5分間)のような短い有効期限にする選択肢もあります。

長期間のキャッシュの設定は、ファイル名方式への切り替えが完了してから行ってください。
順番を逆にすると、ファイル名を変えずに内容だけを更新したファイルが、ブラウザに最長1年間残り続けます。

リソース種別ごとのmax-ageの決め方と、max-ageがブラウザのキャッシュの追い出しに影響しない理由は、別の記事「max-ageは何秒にすべきか」で説明しています。

WordPressのfunctions.phpでは設定できない

WordPressのサイトでは、functions.phpでCache-Controlヘッダーを設定しているケースがあります。
しかし、functions.phpで設定できるのは、WordPress(PHP)が生成するHTMLのレスポンスだけです。

CSSやJavaScriptのような静的ファイルは、PHPを通らずにWebサーバーが直接返します。
そのため、これらのCache-Controlヘッダーは、Webサーバーの設定で指定する必要があります。

Apacheの場合の設定例は、次のとおりです(.htaccess、mod_headersが必要です)。


<IfModule mod_headers.c>
  # まず、対象の静的ファイルすべてに no-cache を付ける
  <FilesMatch "\.(css|js|woff2|svg|png|jpe?g|webp|avif)$">
    Header set Cache-Control "no-cache"
  </FilesMatch>
  # バージョン付きのファイル名(8桁以上の16進数:日付またはハッシュ値)は、上書きして長期キャッシュにする
  <FilesMatch "\.[0-9a-fA-F]{8,}\.(css|js|woff2|svg|png|jpe?g|webp|avif)$">
    Header set Cache-Control "max-age=31536000, immutable"
  </FilesMatch>
</IfModule>

Apacheでは、複数のFilesMatchに一致した場合、後に書いたHeader setが前の値を置き換えます。
そのため、一般的な規則を先に、バージョン付きのファイルの規則を後に書きます。
バージョンの部分は、日付(base.20260925.css)とハッシュ値(style.2bb85551.css)のどちらにも一致するように、「8桁以上の16進数(0〜9とa〜f、大文字を含む)」としています。
ApacheのFilesMatchは、Linuxでは大文字と小文字を区別するので、大文字のA〜Fも明示しています(nginxの例は ~* なので、大文字と小文字を区別しません)。
ハイフン付きの日付(base.2026-09-25.css)、セマンティックバージョン(style.1.2.3.css)、16進数以外の文字が挟まる名前(main.v20260925.css)、ディレクトリにバージョンを入れる方式(/v20260925/base.css)には、この正規表現は一致しないので、そのような規則を使う場合は、正規表現を自分たちのファイル名の規則に合わせて調整してください。
この正規表現に一致するファイル名を、中身だけ差し替えて上書きすることは、1年間古い内容が残る事故につながるので、運用で禁止してください。

nginxの場合の設定例は、次のとおりです。


location ~* "\.[0-9a-f]{8,}\.(css|js|woff2|svg|png|jpe?g|webp|avif)$" {
    add_header Cache-Control "max-age=31536000, immutable";
}

location ~* \.(css|js|woff2|svg|png|jpe?g|webp|avif)$ {
    add_header Cache-Control "no-cache";
}

nginxの公式ドキュメント(ngx_http_core_moduleのlocationディレクティブ)によると、正規表現のlocationは設定ファイルに書かれた順に評価され、最初に一致したものが使われます。
そのため、バージョン付きのファイルに一致するlocationを先に書く必要があります。
順番を逆にすると、バージョン付きのファイルも2つ目のlocationに一致してしまい、no-cacheが付きます。

また、add_headerディレクティブには、注意すべき仕様が2つあります(ngx_http_headers_moduleのドキュメント)。

既存の設定に、CSSや静的ファイルを対象とするlocation(expiresディレクティブを使っているものなど)がすでにある場合は、それとの重複や順序も確認してください。
nginx -T を実行すると、設定の構文を検査したうえで、読み込まれているすべての設定ファイルの内容が表示されるので、該当するserverブロックの中でのlocationの並び順を確認できます。

いずれも、本番環境に適用する前に、検証環境で意図したヘッダーが返ることを確認してください。
確認は、CDNを経由したURLと、オリジンに直接アクセスしたURLの両方で行います。
CDNの設定によっては、オリジンが返したCache-Controlを書き換えてブラウザに渡すことがあるためです。

no-cacheは「使う前に必ずサーバーへ確認する」という指定なので、更新を確実に反映させるには、確認に使うLast-ModifiedやETagが、ファイルの内容の変更と連動している必要があります。
FTPでのアップロードや、更新時刻を保持する設定のrsync(-t)、一部のテーマ同期の仕組みでは、ファイルの内容が変わってもLast-Modifiedが古いまま残ることがあります。
その場合、ETagの作り方によっては、no-cacheを付けていても、サーバーは「変更なし」(304)と答え、古い内容が使われ続けます。

RFC 9110のSection 13.1.3では、リクエストにIf-None-Matchヘッダーがある場合、受信者はIf-Modified-Sinceを無視しなければならない(MUST)とされています。
つまり、ETagがファイルの内容の変更に連動していれば、Last-Modifiedが古いままでも、新しい内容が返ります。
ただし、ETagの作り方はWebサーバーによって異なります。

nginx
ETagは、ファイルの更新時刻とサイズから作られます(ソースコードのsrc/http/ngx_http_core_module.cのngx_http_set_etag、2026年9月30日時点のmasterブランチ)。
etagディレクティブで切り替えられるのは生成するかしないかだけで、ファイルの内容から計算する設定はありません。
更新時刻が古いまま残り、サイズも変わらなかった場合は、ETagも変わりません。
例えば、色の指定を #ffffff から #000000 に置き換えるような、サイズが変わらない変更です。
Apache
2.4のドキュメントでは、FileETagディレクティブの既定値はMTime Size(更新時刻とサイズ)です。
FileETag Digest を指定すると、ファイルの内容のダイジェストからETagを計算します。
ただし、Digestは、Apache 2.4系のCHANGESでは2.4.47の変更として追加されており、2.4.47は配布されたものの発表されなかったため、発表された版としては2.4.48以降で使えます。
使う前に httpd -v でバージョンを確認してください。

更新時刻が古いまま残る環境では、次のいずれかで対応します。

  1. 変更したファイルの更新時刻が、デプロイした時刻になるようにする(rsyncの -t は -a にも含まれ、元のファイルの更新時刻を保持するので、使っている場合はデプロイの手順を見直す)
  2. ファイル名方式に移し、Last-ModifiedやETagによる確認に頼るファイルを減らす
  3. Apacheで、バージョンが2.4.48以降であれば、FileETag Digest を使う

いずれの場合も、デプロイの後に、Last-ModifiedやETagが実際に変わっているかを確認してください。

推測せずに、実測で確かめる

「CDNのキャッシュが原因ではないか」と推測する前に、実際のレスポンスヘッダーを確認すれば、多くのことが分かります。
curlコマンドを使えば、誰でも確認できます。


curl -sI "https://www.example.jp/wp-content/themes/child/base.css"

確認すべき主なヘッダーは、次のとおりです。

Cache-Control、Expires
オリジンサーバーがキャッシュの方針を指定しているかどうか
Last-Modified
ファイルがいつ更新されたか(変更したはずのファイルが、実際に更新されているかどうか)
CDNのキャッシュ状態を示すヘッダー
CDNがキャッシュから返したか、オリジンから取得したか(Gcoreの場合はCacheヘッダーのHITとMISS)
Age
CDNにキャッシュされてからの経過秒数

なお、curl -sI はHEADリクエストで、Accept-Encodingヘッダーも送りません。
CDNが圧縮の有無(Vary: Accept-Encoding)ごとに別のキャッシュを持っている場合、ブラウザとは別のキャッシュの状態を見ていることがあります。
ブラウザに近い条件で確かめるには、GETリクエストにAccept-Encodingを付けて送り、レスポンスヘッダーだけを表示します。


curl -s -o /dev/null -D - -H "Accept-Encoding: br, gzip" "https://www.example.jp/wp-content/themes/child/base.css"

また、URLがリダイレクトされる場合、curlは既定ではリダイレクト先をたどらないので、リダイレクトの応答のヘッダーだけを見て判断することになります。
-L を付けてリダイレクト先までたどるか、リダイレクト先の最終的なURLで確認してください。

CDNがクエリ文字列をキャッシュのキーに含めているかどうかも、実測で確かめられます。
一度も使ったことのない値をクエリ文字列に付けてリクエストし、初回からキャッシュから返された(HIT)場合は、CDNがクエリ文字列を無視しています。


curl -sI "https://www.example.jp/wp-content/themes/child/base.css?ver=test$(date +%s)"

先日のサイトでは、この確認によって、CDNには最新のCSSが保持されていたことと、変更が反映されないファイルのLast-Modifiedが変更日より前のままだったことが分かりました。
また、Chrome Developer Toolsのコンソールには、読み込み先のパスの誤りによるエラーが出ていました。
いずれも、CDNのキャッシュとは無関係の原因です。

ファイル名や ?ver= の値を変えた場合は、HTMLのソースも確認します。
HTMLが古いファイル名を出力していれば、CSSの設定がすべて正しくても、古いCSSが読み込まれます。
その場合は、HTMLのキャッシュ(CDNやページキャッシュのプラグイン)を確認してください。

「反映されない」と感じたら、まずChrome Developer Toolsでコンソールのエラーと読み込まれているファイルを確認し、次にレスポンスヘッダーを実測する。
CDNのキャッシュを疑うのは、その後です。
この順序で確認すれば、関係者のあいだで原因の確認を往復させずに済みます。

まとめ

「反映されない」ときに、自分で確認できる項目を再掲します。

  1. Chrome Developer Toolsのコンソールに、読み込みのエラー(パスの誤りなど)が出ていないか
  2. HTMLのソースに、新しいファイル名(または新しい ?ver= の値)が出力されているか(出力されていなければ、HTMLのキャッシュを確認する)
  3. ネットワークタブで、新しいファイル(新しいファイル名、または新しい ?ver= の値)が読み込まれているか
  4. curl -sI で、CSSやJavaScriptにCache-Controlが付いているか(CDN経由とオリジン直接の両方)
  5. Last-ModifiedやETagが、デプロイの後に実際に変わっているか
  6. CDNがクエリ文字列をキャッシュキーに含めているか(一度も使っていない値でリクエストして、初回がMISSになるか)
  7. ファイル名にバージョンを入れていないファイルに、長期間のキャッシュ(max-age=31536000など)を付けていないか

この記事が事業に意味すること

「CSSを変えたのに反映されない」は、技術的には小さな問題です。
しかし、Webサイトの運用には、サイトの所有者、制作会社、CDNやサーバーの提供者と、複数の関係者がいます。
キャッシュの設計が決まっていないと、問題が起きるたびに関係者のあいだで原因の確認が往復し、そのあいだ公開したはずの変更は反映されません。
誰かが悪いのではなく、「誰がキャッシュを制御しているのか」が決まっていないことが原因です。

更新が確実に反映される設計は、表示速度の問題である前に、運用コストの問題です。
弊社は、CDN(Gcore)の導入時に、キャッシュの制御をオリジンサーバーに集約し、更新の反映手順まで含めて設計します。
あわせて、変更が意図どおりに配信されているかを、レスポンスヘッダーの実測で確認する役割を担います。
関係者のあいだで原因の確認が繰り返されている場合は、お問い合わせフォームからご相談ください。
どのような改善で、どれだけ速くなったかは、高速化成功事例をご覧ください。

参考資料