おそらく、あなたのAPIは毎日何千回も同じJSONを返しています。クライアントがGET /v1/products/42で18KBのデータを取得し、5分後に再度要求しても、何も変更されていないのに同じ18KBを受け取ります。そのたびに帯域幅、シリアライゼーション、データベース読み取りのコストが発生します。
HTTPには、この無駄を解消する仕組みがすでにあります。Cache-Controlはレスポンスの鮮度を指定し、ETagは内容が変更されたかを判定するフィンガープリントを提供します。両者を組み合わせると、再検証が必要なリクエストをボディなしの304 Not Modifiedに変えられます。さらに、書き込み時の更新損失も防止できます。
同じ考え方はクライアント側にも適用できます。ReactでのAPIレスポンスのキャッシュを読んだことがあるなら、この記事はそのサーバーサイド版です。
このガイドでは、HTTPキャッシュの3つの層、304のラウンドトリップ、no-cacheとno-storeの違い、Expressでの実装、そしてApidogでの検証方法を説明します。
HTTPキャッシュの3つの層
APIのHTTPキャッシュは、次の3つの判断に分けて考えると整理しやすくなります。
レイヤー1: 鮮度
クライアントがオリジンへ問い合わせずにレスポンスを再利用できる期間です。
Cache-Control: max-age=60
60秒間はネットワーク通信なしでキャッシュを返せます。最も安価なキャッシュヒットですが、TTLが切れるまで変更を検出できないというトレードオフがあります。
レイヤー2: 検証
レスポンスが古くなっても、クライアントはボディ全体を再ダウンロードする必要がありません。保存していたフィンガープリントを送って、変更の有無だけを確認できます。
-
If-None-Match+ETag: 正確な検証 -
If-Modified-Since+Last-Modified: 1秒単位のタイムスタンプによる古い方式
変更がなければ、サーバーはボディなしの304 Not Modifiedを返します。
レイヤー3: 無効化
データが変更されたとき、古いコピーをどう失効させるかという問題です。
- プライベートキャッシュ:
max-ageで自然に失効 - 共有キャッシュ/CDN: 明示的なパージ、短いTTL、
stale-while-revalidateなどが必要
鮮度は最も大きな節約を生み、検証は鮮度で取りこぼすケースを補い、無効化は両方を正しく保ちます。多くのAPIでは、この3つすべてが必要です。
304 Not Modifiedのラウンドトリップ
製品エンドポイントの流れを見てみましょう。
1. 初回リクエスト
クライアントにはキャッシュがありません。
GET /v1/products/42 HTTP/1.1
Host: api.example.com
2. 初回レスポンス
サーバーはボディとキャッシュメタデータを返します。
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Content-Length: 18432
クライアントはボディとETagを保存します。次の60秒間はサーバーへ問い合わせません。
3. 60秒後の再リクエスト
キャッシュが古くなったため、クライアントはETagを送って再検証します。
GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
4. リソースに変更がない場合
サーバーが現在のETagと受信した値を比較し、一致すれば次を返します。
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
ボディはありません。18KBの代わりに数百バイト程度のヘッダーだけが転送され、クライアントは保存済みのボディをさらに60秒間新鮮なものとして扱います。
製品が変更されていれば、新しいボディとETagを含む通常の200 OKを返します。304 Not Modifiedの解説でも詳しく説明していますが、304はエラーではなく、キャッシュ制御のためのレスポンスです。
条件付きGETでも、ラウンドトリップ、認証、現在のETagを計算するコストは残ります。一方で、ペイロード転送とクライアント側の再解析を省けます。モバイルクライアントがポーリングする大規模なリストAPIでは、エグレスを日常的に60〜90%削減できる場合があります。
APIで重要なCache-Controlディレクティブ
Cache-Controlには多数のディレクティブがあります。JSON APIでは、特に次の5つを理解しておきましょう。
no-storeとno-cache
最も頻繁に起きるキャッシュ設定ミスです。
-
no-store: レスポンスをどのキャッシュにも保存しない -
no-cache: 保存は許可するが、再利用前に必ずオリジンで再検証する
トークン、銀行データ、永続化すべきでないPIIなど、本当に機密性の高いデータにはno-storeを使います。
一方、no-cacheは名前に反してキャッシュを禁止しません。ETagと組み合わせれば、古いデータを表示せず、変更がない場合は304でペイロードを節約できます。「安全のため」とすべてにno-storeを指定すると、条件付きリクエストまで無効になり、毎回フルペイロードを転送することになります。
private
エンドユーザーのクライアントだけがキャッシュでき、共有キャッシュやCDNには保存しないことを示します。
ユーザーごとに異なるレスポンス、特に認証済みAPIにはprivateを含めてください。設定がないと、誤設定されたプロキシがあるユーザーのアカウントデータを別のユーザーへ返す可能性があります。
max-age
レスポンスを新鮮とみなす期間を秒単位で指定します。APIでは30〜300秒程度の短い値から始めるのが実用的です。目的は1日分の通信をなくすことではなく、バーストやポーリングループを吸収することです。
stale-while-revalidate
古いコピーを一時的に返しながら、バックグラウンドで更新するための実用的な中間策です。
Cache-Control: max-age=60, stale-while-revalidate=300
この設定では、キャッシュは最大5分間まで古いコピーを返し、その間に更新を開始できます。ユーザーはすぐにレスポンスを受け取り、オリジンはその後更新されます。Cloudflare、FastlyなどのCDNやブラウザがサポートしています。
認証済みの読み取りエンドポイントでは、次のような設定が適切なデフォルトになります。
Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"
詳細は、HTTPキャッシュの標準であるRFC 9111を参照してください。RFC 9111はRFC 7234を置き換えています。
Strong ETagとWeak ETag
ETagには、W/プレフィックスの有無で区別される2種類があります。
Strong ETag
ETag: "33a64df551425fcc"
バイト単位で完全に一致することを保証します。同じstrong ETagを持つレスポンスは同一です。そのため、バイトレンジリクエストに安全で、If-Matchによる並行性制御にも必要です。
Weak ETag
ETag: W/"33a64df551425fcc"
意味的な同等性だけを保証します。フィールド順やタイムスタンプが異なっていても、意味が同じなら同じキャッシュコピーを利用できます。
注意点は圧縮ミドルウェアです。Nginxや一部のフレームワークは、レスポンスをgzip圧縮するとstrong ETagをweak ETagに書き換えることがあります。圧縮後のバイト列は元のボディと異なるためです。
プロキシ越しの並行性チェックが不可解に失敗する場合は、アプリケーションサーバーが生成していなかったW/プレフィックスが付いていないか確認してください。
デフォルトでは、非圧縮ボディから計算したstrong ETagを使います。異なる表現でも意図的に意味的同等性として扱いたい場合だけ、weak ETagを使ってください。
ETagの生成: ボディハッシュとバージョンカラム
主な戦略は2つです。選択は、どの処理コストを削減したいかで決まります。
レスポンスボディのハッシュ
レスポンスをシリアライズし、MD5やSHA-1でハッシュ化して引用符で囲みます。これはセキュリティ境界ではなくフィンガープリントなので、MD5やSHA-1で十分です。
ETag: "<response-hash>"
スキーマ変更が不要で、内容に対して正確です。ただし304を返す場合でも、完全なレスポンスを構築してハッシュ化する必要があります。帯域幅は節約できますが、CPUやデータベース負荷は減りません。
バージョンカラムまたはupdated_at
安価に取得できる値からETagを生成します。
ETag: "42-v17"
条件付きリクエストを、完全なシリアライゼーションではなくインデックス付きルックアップ1回で処理できます。
ただし、結合先のテーブルを含め、レスポンスに影響するすべての変更でバージョンを更新しなければなりません。更新漏れがあると、古い304を返すという発見しにくい重大なバグになります。
まずはボディハッシュから始めるのが安全です。プロファイリングでシリアライズコストが問題になったホットエンドポイントだけ、バージョンベースのETagへ移行しましょう。
楽観的並行性制御: If-Matchと412
読み取りの帯域幅を節約する同じフィンガープリントで、書き込み時の更新損失も防げます。
例えば、2人の管理者が同時に製品42を読み込んだとします。管理者Aが価格を変更して保存した後、管理者Bが30秒後に誤字を修正して保存すると、Aの価格変更をBの古いデータで上書きしてしまう可能性があります。エラーが発生しないまま、データだけが静かに壊れます。
解決策は、クライアントが最後に見たバージョンを使って更新を条件付きにすることです。
PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
サーバーはIf-Matchを現在のETagと比較します。
- 一致: 更新を適用し、新しいETagとともに
200 OKを返す - 不一致: 更新を拒否し、
412 Precondition Failedを返す
不一致の場合、クライアントは最新データを再取得し、変更を再適用してから再試行します。412 Precondition Failedの詳細も参照してください。
さらに厳格にするなら、If-MatchなしのPUTに対して428 Precondition Requiredを返し、条件付き更新を必須にします。
CDNとプロキシの動作
共有キャッシュはオリジンとクライアントの間で、同じヘッダーを独自のルールに従って処理します。
-
privateはレスポンスをCDNキャッシュから除外します。s-maxage=600を使うと、ブラウザのmax-ageとは別にCDNのTTLを設定できます。 - 多くのCDNは条件付きリクエストでオリジンを再検証します。オリジンが
If-None-Matchに304を返すと、CDNはボディを再取得せず、保存済みコピーのメタデータを更新できます。 -
Varyが正しく設定されているか確認してください。1つのURLでJSONとCSVを返す場合はVary: Acceptが必要です。設定しないと、共有キャッシュがCSVをJSONクライアントへ返す可能性があります。 - 圧縮によってプロキシがETagをweak ETagへ変換することがあります。
Expressの例: ETagの返却とIf-None-Matchの処理
Expressはデフォルトでweak ETagを設定します。手動で処理すれば、strong ETagと412を使った書き込み制御を実装できます。
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
function etagFor(payload) {
const hash = crypto.createHash("sha1")
.update(JSON.stringify(payload))
.digest("hex");
return `"${hash}"`;
}
app.get("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const etag = etagFor(product);
res.set("Cache-Control", "private, max-age=60, stale-while-revalidate=120");
res.set("ETag", etag);
if (req.get("If-None-Match") === etag) {
return res.status(304).end(); // fingerprint matches: no body
}
res.json(product);
});
app.put("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const currentEtag = etagFor(product);
const ifMatch = req.get("If-Match");
if (!ifMatch) {
return res.status(428).json({ error: "If-Match header required" });
}
if (ifMatch !== currentEtag) {
return res.status(412).json({ error: "Resource changed since you fetched it" });
}
const updated = await db.products.update(req.params.id, req.body);
res.set("ETag", etagFor(updated));
res.json(updated);
});
304の分岐でもCache-ControlとETagを返している点に注目してください。RFC 9111では、304は保存済みレスポンスのメタデータを更新するために使われます。クライアントがコピーを新鮮に保つために必要なヘッダーを再送信してください。
Apidogでキャッシュ動作を検証する
ミドルウェアやプロキシが間に入ると、コードが正しくてもキャッシュ動作が変わることがあります。コードではなく、実際のHTTPレベルで検証しましょう。
Apidogなら、手動確認は1分程度で完了します。
-
GET /v1/products/42を送信し、レスポンスヘッダーでETagとCache-Controlを確認します。ETagが引用符で囲まれていることも確認し、値をコピーします。 - 同じリクエストに
If-None-MatchヘッダーとしてETagを追加し、再送信します。ボディなしの304が返るはずです。200が返る場合、サーバーがフィンガープリントを比較していません。 - レコードを変更して再送信し、新しいETag付きの200が返ることを確認します。
デプロイ後も維持できるよう、同じ流れをテストシナリオに組み込みます。
- 1つ目のリクエストでレスポンスヘッダーから
ETagを変数へ抽出 - 2つ目のリクエストで、その値を
If-None-Matchとして送信 - ステータスが304で、ボディが空であることをアサート
- 3つ目のリクエストで古い
If-Match(例:"deadbeefcafe1234")を含むPUTを送信し、412をアサート
APIアサーションでは、ステータスコードとヘッダーのアサーション構文を解説しています。
このシナリオをCIで実行すれば、ETagを削除するミドルウェアのアップグレードを、帯域幅の請求書ではなく失敗したパイプラインとして検出できます。Apidogを無料でダウンロードして、自分のエンドポイントでシナリオを作成してみてください。
よくある質問
no-cacheとno-storeの違いは何ですか?
no-storeはキャッシュを完全に禁止します。ディスクやメモリに保存されないため、すべてのリクエストで完全なレスポンスをダウンロードします。
no-cacheは保存を許可しますが、再利用前に毎回再検証を要求します。ETagと組み合わせれば、変更がない場合は304を返し、ペイロードを節約できます。
no-storeは機密データに限定してください。すべてのレスポンスに適用するのは、APIチームが犯しうる最も高価なCache-Controlのミスの1つです。
ETagはPOSTで機能しますか?
ほとんどの場合、機能しません。ETagはURL上のリソース状態を表すもので、POSTは通常、既存リソースを安定して読み取る操作ではなく、新しいリソースを作成する操作だからです。キャッシュは一般にPOSTレスポンスを保存しません。
書き込みで重要なのは、PUT、PATCH、DELETEにおけるIf-Matchです。これによりETagで更新損失を防げます。POSTレスポンスをキャッシュしたくなった場合、その操作はGETであるべきかもしれません。
304レスポンスはAPIを高速化しますか?
転送量は減りますが、必ずしもオリジン処理が高速化するわけではありません。サーバーはリクエストを受け取り、認証を行い、現在のETagを計算する必要があります。CPU節約効果は、フィンガープリントをどれだけ安価に導出できるかに依存します。
主なメリットは、帯域幅、モバイルバッテリー、低速ネットワークでのレンダリング時間です。導入前後で測定してください。APIパフォーマンステストガイドでは、レイテンシとスループットのベンチマーク方法を解説しています。
ETagとLast-Modifiedのどちらを使うべきですか?
可能であれば両方送信してください。ETagの方が正確で、1秒未満の変更やコンテンツレベルの差異も検出できます。両方が送信された場合、If-None-MatchはIf-Modified-Sinceより優先されます。
Last-Modifiedは、古いクライアントへのフォールバックや、キャッシュが鮮度を推定するためのヒューリスティックとして有用です。1つだけ選ぶなら、ETagを使ってください。
Top comments (0)