キャッシュヒット率の生存時間解析

ブラウザキャッシュのヒット率を生存時間解析で実測する

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

主なポイント

  1. Resource Timing APIでは、再検証も deliveryType が "cache" になるため、先に再検証(responseStatus が304、または transferSize が300)を判定し、次に deliveryType、最後に transferSize が0(ローカルキャッシュ)かどうかで、ヒット・再検証・ネットワークからの取得を判定します。
  2. RUMで観測できるのは、再訪時に「t の時点で残っていたか否か」の1点だけなので、current status dataとして扱い、Kaplan-Meier推定量ではなく、区間ごとの残存割合が単調非増加になるように併合する単調回帰(Pool Adjacent Violators)で S(t) を推定します。
  3. 再訪時にキャッシュが残っている確率は P = ∫S(t)f(t)dt で、生存関数 S(t) と再訪間隔の分布 f(t) の両方で決まるため、同じ S(t) でも、毎日訪問されるサイトと月に1回しか訪問されないサイトでは、P はまったく違います。
  4. ヒット率は訪問加重で、S(t) はcidごとに重みを揃えたユーザー加重で集計し、OS・ブラウザ・利用頻度・流入元で層別した S(t) を使い、標本の大きさは「層 × 時間の区間」のセルあたりで確保します。
  5. 追跡用リソースには、バージョン付きURLで max-age=31536000 を付けた、全ページで読み込まれるサイズの異なる2〜3個のファイルを、サイトと同じ登録可能ドメインに置いて使い、前回読み込んだ時刻をLocalStorageに記録して経過時間 t を計算します。

「キャッシュが効いているはず」を数字にする

ブラウザキャッシュは何日残るのかでは、ブラウザのキャッシュが期待するほど長く残らないことを、仕様、実測データ、実装から説明しました。
では、自分のサイトでは実際にどれだけ残っているのでしょうか。
それは、実ユーザーの計測でしか分かりません。

生存関数そのものの定義と、追い出し方式やCheの近似との関係は、キャッシュの寿命はなぜ確率で扱うのかで説明しています。
この記事では、Resource Timing APIでキャッシュのヒットを判定する方法と、その結果を生存時間解析として扱ってキャッシュの有効性を計算する方法、そしてRUM(Real User Monitoring)での実装手順を説明します。
統計の話が中心になるので、実務上の結論だけを知りたい方は、「ブラウザキャッシュは何日残るのか」の「実務上の結論」の節をご覧ください。

この記事で測れるもの、測れないもの

測れるのは、次の2つです。

測れないのは、キャッシュが消えた正確な時刻と、その原因(追い出し、ユーザーによる消去、ストレージの逼迫など)です。
S(t) は「理由を問わず残っていた割合」であり、ヒット率は「訪問数で重み付けした割合」で、この2つは別の量です。
この違いは、集計の重み付けの節で扱います。

実装の最短手順は、次のとおりです。
詳細は以降の節で説明します。

  1. 自オリジンに置いた、バージョン付きURLで長期キャッシュのファイルを、サイズの違うもの3つ選ぶ
  2. PerformanceObserverで、そのファイルの読み込みが「キャッシュから」「再検証」「ネットワークから」のどれだったかを判定する
  3. 前回そのファイルを読み込んだ時刻をLocalStorageに記録し、経過時間 t を計算する
  4. 判定結果、t、OS・ブラウザ・流入元、匿名の識別子を、サンプリングして送信する
  5. 再訪(t がある)データを、OS・ブラウザ・流入元で層別し、t を区間に分けて残存割合を集計する
  6. 区間の割合が単調非増加になるように併合し、層ごとの S(t) を得る
  7. 層別に、S(t) が0.5を切る時間と、再訪PVのヒット率を並べて読む

Resource Timing APIでヒットを判定する

実際にどれだけキャッシュが残っているかは、実ユーザーの計測でしか分かりません。
Resource Timing API(ブラウザが各リソースの読み込みにかかった時間や転送量を、JavaScriptから取得できる標準のAPI)を使うと、各リソースがキャッシュから読み込まれたかどうかを判定できます。

判定に使える属性は3つあります。

表は横にスクロールできます。

Resource Timing APIによるキャッシュ状態の判定
属性値意味注意
deliveryType"cache"キャッシュモードが空でない(ローカルキャッシュ、または再検証のどちらか)。ネットワークから本文を取得しなかったことを表す再検証(304)も "cache" になるので、単独ではヒットと再検証を区別できない。対応していないブラウザでは空文字列
responseStatus304再検証(サーバーに問い合わせ、更新なしの応答)クロスオリジンでno-corsの場合は0になる。対応していないブラウザでは未定義
transferSize0ローカルキャッシュから読み込まれた(仕様上の値)Timing-Allow-Originがない他オリジンでは常に0になるため、単独では判定できない
transferSize300再検証(仕様上、検証済みの応答は定数300)
transferSizeencodedBodySize + 300ネットワークから取得(仕様上、ヘッダーの実サイズの代わりに定数300を加算)

Resource Timingの仕様では、transferSizeは「本文の転送サイズに定数300を加えた値」と定義されています。
定数300は、HTTPヘッダーの実際のサイズを公開すると特定のCookieの有無が分かってしまうため、その代わりに置かれたものです。
再検証(304)ではヘッダーだけが転送されるので、transferSizeは300になります。
かつて使われていた「transferSizeがencodedBodySizeより小さければ再検証」という判定は、数百バイトのSVGやファビコンのように小さなファイルでは成り立ちません。
判定の順序にも注意が必要です。
仕様では、deliveryTypeはキャッシュモードが空でなければ "cache" になり、キャッシュモードにはローカルキャッシュ("local")と再検証("validated")の両方が含まれます。
つまり、deliveryTypeを先に見ると、再検証もヒットに数えてしまいます。
再検証の判定(responseStatusが304、またはtransferSizeが300)を先に行い、その後でdeliveryTypeを見る必要があります。
また、deliveryTypeとresponseStatusは、すべてのブラウザが対応しているわけではありません。
対応していないブラウザでは、実際にはtransferSizeが主な判定材料になります。
transferSizeが定数300に揃う前の実装では、再検証のときに実際のヘッダー長(数百バイト前後の値)を返すことがあり、300との厳密一致では判定できません。
responseStatusを先に見るのは、この差を吸収するためでもあります。
それでも判定できない値(1〜299など、仕様の定義に当てはまらない正の値)は、実装の差として「不明」に分類し、集計から除外します。
また、定数300に揃う前の実装で responseStatus も取れない場合、実際のヘッダー長が300を超える再検証は「ネットワークから取得」(miss)に分類されてしまいます。
ビーコンには status と transfer を含めているので、集計の前に、responseStatus がなく transferSize が encodedBodySize に比べて極端に小さい記録を確認し、再検証に分類し直してください。
追跡用リソースが数十KB以上であれば、この誤分類の影響は限られます。

これをRUM(Real User Monitoring)で集計し、OS別・前回訪問からの経過日数別に分けると、自分のサイトのキャッシュヒット率の実態を定量的に評価できます。
「1年キャッシュさせている」という設定ではなく、「再訪のページビューの何%がキャッシュから読めたか」(ヒット率)と「層ごとのキャッシュの寿命」(S(t))という実測値で、性能を管理してください。

生存時間解析として扱う

「キャッシュがどれだけ有効か」は、集計したデータから統計的に計算できます。
キャッシュのエントリが「消える」という事象を扱う時間-事象データなので、生存時間解析の枠組みがそのまま当てはまります。

前回そのリソースを読み込んだ時刻(ネットワーク経由かキャッシュからかを問いません)からの経過時間を t とし、t の時点でキャッシュがまだ残っている確率を生存関数 S(t) とします。
起点をネットワーク経由の取得に限定しないのは、LRU型の追い出しが最終利用時刻を基準にするため、キャッシュヒットでも時計がリセットされるからです。
また、分析の対象は t が max-age より短い範囲に限ります。
max-age を超えたリソースは追い出されていなくても再検証(キャッシュを使う前にサーバーへ更新の有無を問い合わせること)が発生するため、消失と期限切れが混ざるからです。

なお、Facebookの「モバイルでは、リクエストの50%が12時間以内のキャッシュしか持っていない」という結果は、S(t) そのものではありません。
ヒットしたリクエストにおけるキャッシュの古さの分布は、S(t) と再訪間隔の分布の両方に依存します。
キャッシュがまったく消えないサイトでも、ユーザーが半日ごとに訪問していれば、ヒットしたリクエストの半分は12時間以内の古さになります。
Facebookの数字は、キャッシュの消失と訪問頻度の両方を反映した値であり、片方だけを取り出すことはできません。
S(t) を知るには、次に述べる方法で自分のデータから推定する必要があります。

RUMで観測できるのは再訪時の1点だけです。
再訪時にヒットしていれば「t の時点ではまだ残っていた」、ミスしていれば「t より前のどこかで消えた」ということしか分からず、消えた正確な時刻は観測できません。
これは統計学で current status data(Case I 区間打ち切りデータ)と呼ばれる形式で、右打ち切りを前提とするKaplan-Meier推定量はそのままでは使えません。
この形式の生存関数の最尤推定量は、経過時間 t について ヒット率が単調非増加になるように制約した単調回帰(isotonic regression、Pool Adjacent Violators アルゴリズム)で求まります。
実務上は、経過時間を区間(1時間、6時間、12時間、1日、3日、7日、30日など)に分けて各区間のヒット率を集計し、隣り合う区間で大小が逆転している箇所を併合すれば、十分な精度の生存曲線が描けます。

この推定には、再訪の時刻がキャッシュの消失時刻と独立であるという前提があります。
キャッシュの場合、この前提は自明ではありません。
再訪間隔が長いユーザーは、その間に他のサイトを多く閲覧してキャッシュを圧迫している可能性があり、Firefoxのfrecencyのように、そのサイトの利用頻度が追い出しの基準に直接入っている実装もあります。
独立性が崩れると、推定した S(t) は偏ります。
この前提の扱いは、集計の節で説明します。

生存曲線だけでは、キャッシュの有効性は決まりません。
再訪間隔の分布と組み合わせる必要があります。
再訪間隔の確率密度関数を f(t) とすると、上記の独立性の下で、再訪時にキャッシュが残っている確率 P は次のとおりです。


P = ∫ S(t) · f(t) dt

図で示すと、次のようになります。

図1:生存関数S(t)と再訪時の観測(ヒットとミス)。図2:再訪間隔の分布f(t)と、その積S(t)·f(t)の面積がP
模式図(仮定した関数による例で、実測値ではありません)。図1:生存関数 S(t) と再訪時に観測できること。図2:再訪間隔の分布 f(t) と、その積 S(t)·f(t)

図1は生存関数 S(t) です。
再訪時に観測できるのは、曲線上の1点で「まだ残っていた(○)」か「すでに消えていた(×)」かだけであり、消えた時刻そのものは分かりません。
図2は、再訪間隔の分布 f(t) と、それに S(t) を掛けた曲線です。
塗りつぶした面積が、再訪時にキャッシュが残っている確率 P です。
f(t) の山が右(再訪間隔が長い側)に寄るほど、S(t) が小さい領域に重みがかかり、面積は小さくなります。
なお、この図の S(t) と f(t) は説明のために仮定した関数で、実測値ではありません。
図中の P ≒ 0.44 という値も仮定から出たもので、Facebookの44.6%とは無関係です。

つまり、「再訪間隔の分布で重み付けした生存関数の期待値」です。
S(t) と f(t) を同じデータから推定した場合、この値は観測されたヒット率と一致するので、式そのものが新しい情報を生むわけではありません。
この式の価値は、S(t) と f(t) を分けて扱えることにあります。
ブラウザ別・OS別に推定した S(t) を別のサイトの再訪間隔の分布に当てはめれば、そのサイトのヒット率を予測できます。
同じ S(t) でも、毎日訪問されるサイトと月に1回しか訪問されないサイトでは、P はまったく違います。
Facebookでさえ44.6%のユーザーが空のキャッシュだったのなら、訪問頻度の低い企業サイトではさらに低い、とブラウザキャッシュは何日残るのかで書いた根拠を定量化したものがこの式です。

さらに、リソース i ごとの転送サイズ(圧縮後のバイト数。ヒット時のtransferSizeは0なので、encodedBodySizeを使います) Bi を掛けて合計すれば、1回の再訪あたりにキャッシュによって節約できる転送量の期待値になります。


E[1回の再訪あたりの節約バイト数] = Σ Bi · Pi

集計するときには、次の点に注意してください。

RUMでの実装手順

通常のRUMが取っている値(LCPやTTFB)に加えて、「前回いつ読み込んだか」を自分で記録する必要があります。
RUM製品の標準機能では取れないので、計測用のJavaScriptを追加します。

追跡用リソースを決める

すべてのリソースを追跡する必要はありません。
次の条件を満たす数個のファイルを「追跡用リソース」として決めます。

例として、メインのCSS(数十KB)、ロゴ画像(数KB)、Webフォント(数百KB)です。
追跡用リソースは、サイトと同じ登録可能ドメインのホストに置いてください。
理由は2つあります。
1つは、他オリジンのリソースは、レスポンスに Timing-Allow-Origin ヘッダーがないとResource Timing APIのサイズ情報が0に隠され、ヒット判定ができないことです。
もう1つは、現在の主要ブラウザがキャッシュをトップレベルサイトごとに分割(パーティショニング)しているため、別のサイトから読み込まれた同じURLのファイルは、別のキャッシュエントリとして扱われることです。
自オリジンに置けば、どちらの問題も避けられます。

クライアント側で記録する

ページ読み込み後に、追跡用リソースごとに次を判定・記録します。
Resource Timingのエントリは、PerformanceObserverで buffered: true を指定して受け取ります。
load時に performance.getEntriesByType を1回だけ呼ぶ方法では、バッファの上限を超えたエントリや、load後に読み込まれるフォントなどを取りこぼすためです。


const TRACKED = [
  'https://www.example.jp/assets/main.20260925.css',
  'https://www.example.jp/assets/logo.20260925.svg',
  'https://www.example.jp/assets/font.20260925.woff2',
];
const KEY = 'cache_probe';
const SAMPLE_RATE = 0.05;   // 5%のページビューだけ送信する

// 判定:再検証を先に見る(仕様上、再検証も deliveryType === 'cache' になるため)
function classify(e) {
  if (e.responseStatus === 304 || e.transferSize === 300) return 'revalidated';
  if (e.deliveryType === 'cache') return 'hit';
  if (e.transferSize === 0 && e.decodedBodySize > 0) return 'hit';   // 仕様:ローカルキャッシュは0
  if (e.transferSize > 300) return 'miss';                            // 仕様:本文 + 300
  return 'unknown';                                                   // 1〜299など、実装差は集計から除外
}

// URLはクエリ文字列とフラグメントを除いて比較する
function normalize(u) { try { const x = new URL(u); return x.origin + x.pathname; } catch (_) { return u; } }
const TRACKED_SET = new Set(TRACKED.map(normalize));

function start() {
  const nav = performance.getEntriesByType('navigation')[0];
  if (nav && nav.type === 'back_forward') return;   // Back/Forward Cacheからの復元は計測しない
  const sampled = Math.random() < SAMPLE_RATE;

  let state = {};
  try { state = JSON.parse(localStorage.getItem(KEY)) || {}; } catch (_) {}
  if (!state.cid) {                                     // 匿名の識別子(ユーザー単位の集計用)
    state.cid = (crypto.randomUUID && crypto.randomUUID())
      || Date.now().toString(36) + Math.random().toString(36).slice(2);   // 古いWebView向けの代替
  }
  const seen = new Map();   // 正規化URL -> 記録済みの結果(同じURLを二重に数えない)

  function record(e) {
    const key = normalize(e.name);
    if (!TRACKED_SET.has(key) || seen.has(key)) return;
    const loadedAt = Math.round(performance.timeOrigin + e.responseEnd);   // このリソースを読み込んだ時刻
    const last = state[key];                                              // 前回読み込んだ時刻(ms)
    seen.set(key, {
      url: key,
      result: classify(e),
      delivery: e.deliveryType || '',
      status: e.responseStatus ?? null,
      transfer: e.transferSize,
      sw: e.workerStart > 0,          // このリソースの取得にService Workerが介在したか
      initiator: e.initiatorType,
      t_sec: last ? Math.round((loadedAt - last) / 1000) : null,   // null = 初回または記録なし
      size: e.encodedBodySize,
    });
    state[key] = loadedAt;              // ヒットでもミスでも「最終利用時刻」を更新
  }

  const po = new PerformanceObserver((list) => list.getEntries().forEach(record));
  po.observe({ type: 'resource', buffered: true });

  window.addEventListener('pagehide', () => {
    // Observerのコールバックが間に合わない場合に備えて、送信直前にもう一度走査する
    performance.getEntriesByType('resource').forEach(record);
    try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (_) {}
    if (!sampled || seen.size === 0) return;
    navigator.sendBeacon('/rum/cache-probe', JSON.stringify({
      cid: state.cid,
      results: [...seen.values()],
      ua: navigator.userAgent,
      referrer_type: classifyReferrer(document.referrer),   // SNS・検索・直接などに分類する関数(省略)
      path: location.pathname,
      ts: Date.now(),
    }));
  }, { once: true });
}

// 事前レンダリング中のページは、実際に表示された(活性化された)時点で計測を始める
if (document.prerendering) {
  document.addEventListener('prerenderingchange', start, { once: true });
} else {
  start();
}

設計上の要点は次のとおりです。

送信する項目

RUMで送信する項目と用途
項目用途
url、result、t_sec、sizeS(t)の推定とヒット率(本体)
delivery、status、transfer、initiator、sw判定の根拠の確認と、Service Worker(リソースごと)やプリフェッチを後から除外するための層別
User-AgentOS・ブラウザ・WebViewの層別(Android WebViewは「wv」の文字列で判別できます)
匿名のクライアント識別子 cid(ランダム値をLocalStorageに保存)ユーザー単位の集計(「ユーザーのキャッシュは何時間持つか」を出すとき)。LocalStorageが消えると識別子も消えるため、SafariではITPの影響で同じユーザーが別の識別子で数えられることがあります
ページのパス、参照元の種別(SNS、検索、直接)流入経路別の層別

個人を特定する情報は送りません。
ただし、ランダムな識別子であっても端末に保存して外部に送信する以上、電気通信事業法の外部送信規律(2023年6月施行)の対象になり得ます。
プライバシーポリシーへの記載が必要かどうかは、法務に確認してください。

集計する

集計の前に、何を知りたいのかで重み付けを決めます。
RUMの標本は訪問回数に比例して集まるので、そのまま集計すると、頻繁に訪問するユーザーが過大に代表されます。
これを補正するかどうかは、知りたい量によって違います。

知りたい量と、集計の重み付け
知りたい量重み意味
再訪のページビューのヒット率、節約できる転送量の期待値訪問加重(そのまま集計)トラフィックのうち何%がキャッシュから読めたか
「ユーザーのキャッシュは何時間持つか」(生存関数 S(t))ユーザー加重(cidごとに重みを揃える)典型的なユーザーの端末でのキャッシュの寿命
流入経路別の初回率訪問加重その経路から来た訪問のうち、キャッシュが空だった割合

S(t) と再訪間隔の分布 f(t) を同じ重みで推定すれば、P = ∫S(t)f(t)dt は観測されたヒット率と一致し、検算に使えます。
重みを混ぜると(S(t) はユーザー加重、f(t) は訪問加重、など)、この検算は成り立ちません。
また、「ユーザーごとに1標本に絞る」方法は、S(t) の推定には使えますが、f(t) の情報を捨てるので、ヒット率の推定には使えません。
寿命曲線用とヒット率用で、手順を分けてください。

S(t) の推定は、次の手順です。

  1. t_sec が null でない再訪データのうち、result が hit・revalidated・miss のいずれかで(unknownを除く)、delivery が "navigational-prefetch" でなく、sw が偽のものを使う(プリフェッチとService Workerを経由した読み込みを除くため。メモリキャッシュとディスクキャッシュは区別できないので、両方を含みます)
  2. t_sec が追跡用リソースの max-age より短いものだけを使う(期限切れによる再検証を、追い出しと混ぜないため)
  3. OS・ブラウザ・WebView・流入元・リソースで層別する
  4. t を区間に分ける(1時間、6時間、12時間、1日、3日、7日、30日)
  5. 区間ごとに「hit + revalidated」の割合を計算する(S(t) を出すなら、cidごとに重みを揃える)
  6. 隣り合う区間で割合が逆転していれば併合する(Pool Adjacent Violators)。これが current status data に対する生存関数のノンパラメトリック最尤推定です

この推定には、知っておくべき性質があります。

独立性の前提についても、点検が必要です。
この推定は、再訪の時刻がキャッシュの消失時刻と独立であることを仮定しています。
キャッシュの場合、この仮定は崩れやすい方向が分かっています。
再訪間隔が長いユーザーは、その間に他のサイトを多く見てキャッシュを圧迫している可能性があり、Firefoxのfrecencyのように、そのサイトの利用頻度が追い出しの基準に直接入っている実装もあります。
利用頻度の高いユーザーは短い t に集まり、かつキャッシュも残りやすいので、層別せずに全体で1本の S(t) を引くと、混合比が t によって変わることで曲線が歪みます。
現実的な対処は、OS・ブラウザ・利用頻度・流入元で層別し、「層の中では独立」という条件付き独立を仮定して、層別の S(t) をそのまま使うことです。
層をまたいだ全体の1本の曲線は、参考値にとどめてください。
利用頻度の層は、観測期間を固定したうえで(たとえば直近30日間の訪問回数で)区分します。
観測期間を固定しないと、訪問回数と再訪間隔が同じ情報になり、層別の意味がなくなります。

t_sec が null の訪問の割合は、「キャッシュが空だった割合」の上限側の指標です。
null には、本当の初回訪問のほかに、LocalStorageが消えた再訪(SafariのITP、プライベートブラウジング、別のプロファイルやWebView)、計測スクリプト導入前の再訪、別の端末からの訪問、ストレージへの書き込み失敗が混ざります。
Facebookの2015年の調査の44.6%は、計測用の画像に対するユーザー単位の空キャッシュ率で、この null の割合とは定義が違います。
自社の null の割合をFacebookの数字と比べる場合は、分母をユーザーにするか訪問にするかを揃え、導入直後の期間を除外し、LocalStorageの欠損とHTTPキャッシュが空であることを区別できる範囲で比較してください。

結果の使い方

S(t) は「理由を問わず残っていた割合」なので、S(t) が低いことが分かっても、原因(容量超過による追い出し、ユーザーによる消去、ブラウザやOSの更新、ストレージの逼迫)は特定できません。
原因ごとの対策(ファイルの縮小、訪問頻度を上げる施策、CDNへの依存)のどれが効くかは、層別の比較で推定します。
サイズ別の S(t) に差があれば容量超過が主因、OS別に差があればプラットフォームの挙動が主因、というように読みます。

節約できる転送量の期待値 ΣBiPi は、追跡用リソースについての値です。
ページ全体の転送削減量に読み替えるには、追跡用リソースがページ全体の転送量に占める割合を掛ける必要があります。

まとめ

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

「リピーターはキャッシュが効いているから速い」という前提は、KPIの設計に入り込んでいることがあります。
初回訪問と再訪問で表示速度の目標を分けている、あるいは再訪問の速度を計測していない、という運用がその例です。
この記事の方法で実測すると、その前提が自社のサイトで成り立っているかどうかが、数字で分かります。
成り立っていなければ、KPIも、性能改善の優先順位も、変える必要があります。

弊社は、国内7都市の計測拠点と4キャリアの5G回線からの合成監視(Synthetic Monitoring)と、この記事のようなRUMの設計を組み合わせて、表示速度を統計的品質管理の対象として扱っています。
自社のサイトの再訪問の実態を把握したい場合や、計測の設計から相談したい場合は、お問い合わせフォームからご相談ください。
どのような改善で、どれだけ速くなったかは、高速化成功事例をご覧ください。

参考資料