PerformanceResourceTiming API で HTTP Cache のヒット率を知る

はじめに

こんにちは、Repro Booster という製品の開発責任者/プロダクトマネジメントを担当しているEdward Fox(edwardkenfox)です。

WebサイトやWebアプリケーションの表示速度を考える上では、キャッシュの活用はとても大事なテーマです。一口にキャッシュといっても、Webの文脈だけで見ても様々なレイヤーや用途のキャッシュが存在します。今回は昔ながらのキャッシュ、いわゆる HTTP Cache に的を絞り、HTTP Cache のヒット率について考えてみたいと思います。

さまざまなキャッシュレイヤー

前述のように、Webにおけるキャッシュには用途やレイヤーの異なる様々な種類のものが存在します。Webサイト/Webアプリケーションを開発する上で気にかけるべきものは、おおよそ次のようなものが該当するでしょう。

  • HTTP Cache (ブラウザキャッシュ)
  • CacheStorage
  • BFCache や ClosedTabCache などブラウザ独自の実装のもの
  • CDN上でのキャッシュ

もちろんこれ以外にも、サーバーの前段に設置するリバースプロキシなども存在します。が、ブラウザから見ればネットワークから先は「オリジン」と同一視して問題のないケースがほとんどなため、ここでは割愛します。

Webページとサーバーの間に存在するキャッシュの前後関係を整理すると、次の図のようになります。なお、この図はキャッシュについての完全な説明ではなく、一般的な構成を簡素化して記述したものである点に留意してください。

上記の構成やキャッシュが参照される流れを把握し、各レイヤーにおけるキャッシュを適切に活用することで、サイトのパフォーマンスを改善したり、あるいはコンテンツ配信を効率化/コスト削減することが可能になります。

キャッシュのヒット率

キャッシュデータが活用されることを一般に「キャッシュがヒットする」と言い、あるリソースについてどの程度キャッシュが活用されたのかを「ヒット率」として集計することがあります。CDNを利用して画像やアセットファイルを配信する場合を考えてみましょう。

CDNに置いたキャッシュデータがどの程度ヒットするかは、それぞれのリソースの有効期限やキャッシュキーの設計であったり、リソースごとのアクセス頻度に大きく依存します。リソースごとに設定した cache-control ヘッダを通してCDNに対してキャッシュの挙動を指示するわけですが、設定した値通りの挙動になるとは限りません。例えば、アクセスの少ないリソースについては有効期限を迎える前にキャッシュが evict され、キャッシュヒットとならないことが考えられます。また基本的にはヒット率はできるだけ高いのが望ましいのですが、だからといって闇雲にキャッシュの有効期限を伸ばすことができないケースも多いのが事実です。例えば時間の経過によってコンテンツの鮮度が落ち、古い情報が参照されてしまうのが望ましくないリソースについては、あまり長い時間キャッシュさせることはできません。こういった可能性を考えると、キャッシュを活用し配信効率やパフォーマンスを最適化していく上では、キャッシュヒット率はクリティカルな情報だと言えるでしょう。反対にいうと、ヒット率が見れないとキャッシュ活用の様子はかなり不透明になるため、選択したキャッシュ戦略は適切だったか、あるいは投資対効果が得られているかといった分析が難しくなってしまいます。

HTTP Cache の課題

前章ではCDN上のキャッシュを例に説明しましたが、キャッシュ活用の文脈でまず第一に検討されるのは HTTP Cache のケースが多いでしょう。これはブラウザキャッシュとも呼ばれるもので、文字通りHTTPを通して取得されるリソースをクライアントにキャッシュさせるものです。 cache-control ヘッダには多くのディレクティブが存在し多様なキャッシュの振る舞いが実現できます。多くの場合では、まず「ブラウザが1度ダウンロードしたリソースをキャッシュとして保持し、2回目以降のリクエストに際してキャッシュしたデータを活用することで通信(量/数)を減らす」といったユースケースに対応させるために利用することになります。

HTTP Cache の歴史は古く、 HTTP/1.0 の時代から ExpiresIf-Modified-Since といったヘッダにより利用が可能でした。HTTP Cache がヒットすればわざわざネットワークからリソースを取得する必要がなくなるため、CDNの利用よりも先に検討されることも多いと思います(そもそもの用途が違うため、一概に比較はできませんが)。

しかしながら、HTTP Cacheには「ブラウザが提供する正式な情報としてキャッシュヒットが検知できない」という、一筋縄ではいかない課題がありました。キャッシュがヒットした際にはサーバーに対する通信が発生しないため(304 Not Modified ステータスを返しボディを省略するケースなどを除く)、サーバー側のログから集計したアクセス数と、Google AnalyticsのようにJavaScriptを使って計測したページビュー数などを突合すれば、おおよそどの程度キャッシュがヒットしたかを推し量ることは可能です。しかしながら、キャッシュはヒットしたけどJavaScriptが実行される前に離脱したケースなどは計上されないといった問題も残るため、厳密にどの程度キャッシュがヒットしたのかを知ることは難しいです。

PerformanceResourceTiming

こういった課題は存在していたのですが、実は比較的最近できた PerformanceResourceTiming というAPIを利用することでJavaScriptを使って HTTP Cache がヒットしたかどうかを把握できるようになりました。

このAPI自体はその名の通りリソースのパフォーマンスに関する情報を返すためのもので、リソースの読み込みにおける各ステップの所要時間をオブジェクトとして返します。おおよそ次のようなタイミングに関する情報が取得可能です。

このAPIが返す一連の情報の中にはリソースのサイズに関するものがあり、このうち transferSize (厳密には decodedBodySize も)を見ることで、間接的に「そのリソースがキャッシュから返ってきたものか」を知ることができます。

LCPと判定された画像について、HTTP Cacheがヒットしたかどうかを取得するサンプルコードを見てみましょう。

const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lcpEntry = entries[entries.length - 1];

  console.log(`LCP is ${lcpEntry.url}`);

  const resources = performance.getEntriesByType("resource");
  resources.forEach((entry) => {
    if (entry.name === lcpEntry.url && entry.transferSize === 0 && entry.decodedBodySize > 0) {
      console.log(`LCP resource was a cache hit`);
    }
  });
});

observer.observe({ type: "largest-contentful-paint", buffered: true });

上記のコードでは次のような処理を実施しています。

  1. PerformanceObserver インスタンスを生成し、LCPに該当する要素が見つかった際にコールバックを実行させる
    • buffered: true とすることで、このコードが実行される前に存在していたエントリも取得する
  2. 見つかったLCP要素について、performance.getEntriesByType("resource")PerformanceResourceTiming を取得する
  3. PerformanceResourceTimingtransferSize が 0 であり(キャッシュからのヒットを示唆している)、かつ decodedBodySize が 0 以上であることをチェックする(クロスオリジンなリソースの場合にはキャッシュヒット是非に関わらず 0 となるため)
  4. 上記の条件が真であればキャッシュからヒットしたと判断しログを出力する

テスト用に画像をいくつか埋め込んだだけのHTMLを用意して検証してみます。ページを表示したあとで上記のコードを実行した結果を見てみましょう。

はじめてページを開いたときには、すべてのリソースがネットワークから取得されます。Devtoolsの Size カラムから、それぞれのリソースのサイズが確認できます。この数値は、実際のリソースのバイト数ではなく通信経路上のサイズのため、圧縮されたコンテンツでは圧縮後のサイズとなる点に注意してください。この状態で上記のスクリプトを実行すると、LCPとして判定された画像のURLのみがコンソールに出力されているのが分かります。

続けて、画像ファイルについて cache-control: max-age=86400 など適当なヘッダーがセットされている状態でページをリロードしてみます。

Size 欄に (memory cache) と出ており、ネットワークからではなく HTTP Cache がメモリからヒットしたことが分かります。ここで同じスクリプトを実行すると、今度は 1.jpg が cache hit だった、と出ていますね。このようにして、リソースがそれぞれ HTTP Cache からヒットしたものなのか、が判定できる、という形です。

この方法でキャッシュヒットの情報を取得し、それらデータを分析基盤に送ったり、テレメトリとして監視ツールに集めることでヒット率の集計が可能になります。実際にBoosterでは、GA4に送信したデータをBigQueryにエクスポートし、バッチ処理で集計したデータをLookerというBIツールで可視化しており、キャッシュデータのヒット率などを追えるようにしています。

なお、より厳密に「リソースがどのように取得されたか」を示すための deliveryType というプロパティも存在します。しかしながら、本記事の執筆時点では Firefox/Safari で非対応となっており、網羅的な情報を得る用途では利用が難しい状況です。一方で transferSize は全てのモダンブラウザが対応しているため、現時点ではこちらを参照するのが良いでしょう。

またリソースがクロスオリジンな場合も上記と同様に transferSize が 0 になる点に注意してください。クロスオリジンで、かつそのオリジンからのレスポンスに手を入れられる場合は Timing-Allow-Origin を指定すればこの問題は回避できます。

まとめ

PerformanceResourceTiming を利用し、HTTP Cache がヒットしたかどうかを調べる方法について解説しました。キャッシュの活用は配信効率の向上やコストの最適化においてとても重要なテーマです。こういった情報を活用し、適切なキャッシュ戦略の立案と可視化・分析を考える際の一助となれば幸いです。

We Are Hiring!

Repro Boosterでは開発者の採用を進めています。「タグを入れたその日から、Webサイトが速くなる」というプロダクトに少しでも興味を持っていただけたら、ぜひ求人情報をご覧ください。

Wantedly – タグを入れるだけでサイトを速くする。新規プロダクトのエンジニア募集!