APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務

(更新: 2026年6月21日 ) HTTP キャッシュ API パフォーマンス CDN
結論

API キャッシュは Cache-Control で「どこに・どれくらい保存するか」ETag + If-None-Match で「中身が変わったか」 の二段構えが基本。304 Not Modified でボディ転送を省略し、stale-while-revalidate で体感速度を上げる。CDN 利用時は キャッシュキー設計Vary を誤るとキャッシュポイズニングのリスクがある。まず curl でヘッダーを検証し、エンドポイント単位の鮮度要件表を作る。

なぜ API キャッシュを設計段階で決めるのか

フロントエンドの最適化だけでは、同じ JSON を何度も取得するコストは消えない。React Query や SWR ライブラリはクライアント側のキャッシュを賢くするが、HTTP レイヤー を設計していなければ、毎回オリジンまで往復する。ブラウザ、CDN、リバースプロキシは HTTP ヘッダー一行 で挙動が変わる。

設計を後回しにすると、次のようなトラブルが起きやすい。

  • 本番で古い在庫数が表示される(CDN が昨日の JSON を返し続ける)
  • デプロイ後もユーザーが旧版 UI を見る(max-age=86400 の API がパージされていない)
  • ユーザー A の注文履歴がユーザー B に表示される(CDN キャッシュキーに Authorization が含まれていない、または Vary 欠落)
  • 304 が一度も返らず帯域が削減されない(ETag がリクエストごとにランダム生成されている)

キャッシュ設計のゴールは次の 2 点だ。

  • 鮮度(Freshness) — ユーザーが見るデータが許容範囲内で新しいこと
  • 効率(Efficiency) — 帯域・サーバー負荷・TTFB を減らすこと

この 2 つはトレードオフになる。在庫 API は秒単位の鮮度が必要だが、ブログ一覧は 5 分古くても問題ない。だから「何を」「どの層で」「どれくらい」キャッシュするかを、エンドポイントごと に決めておく。

HTTP キャッシュの主要プレイヤーは次の 3 層だ。

  1. ブラウザ(プライベートキャッシュ)private / max-age に従いローカル保持
  2. CDN・リバースプロキシ(共有キャッシュ)public / s-maxage を参照。private なら通常保存しない
  3. オリジンサーバー — キャッシュミス時に生成。ETag 一致なら 304 でボディ省略

設計段階でこの 3 層を意識しておけば、後から「CDN を挟んだら動かない」という手戻りを減らせる。

HTTP キャッシュの基本用語

実装に入る前に、RFC 7234(Caching)で使われる用語を整理しておく。

用語意味
Fresh(新鮮)max-age 内。再検証なしでキャッシュを返してよい
Stale(古い)max-age 経過後。再検証が必要、または SWR 等の例外適用
Revalidation(再検証)オリジンへ条件付きリクエスト(If-None-Match 等)を送り、304 か 200 を受け取る
Shared cacheCDN など複数ユーザーで共有するキャッシュ
Private cacheブラウザなど単一ユーザー向けキャッシュ

Age ヘッダーは、共有キャッシュがそのレスポンスを保持してきた秒数を示す。CDN 経由で Age: 580 が付いていれば、エッジで約 10 分経過している。curl で確認できる。

curl -sSI 'https://api.example.com/v1/public/categories' \
  | grep -iE '^(age|cache-control|etag|x-cache):'

Cache-Control:ディレクティブ一覧と設計の考え方

Cache-Control はレスポンス(ときにリクエスト)に付与し、キャッシュの 保存可否・期間・再検証の仕方 を指示する。API 設計で触れるディレクティブを表にまとめる。

ディレクティブ対象意味
max-age=秒すべてこの秒数までは「新鮮(fresh)」とみなし、再検証なしで返せる
s-maxage=秒共有キャッシュ(CDN)CDN 向けの max-age。ブラウザは無視する
no-storeすべて一切保存しない。機密・決済向け
no-cacheすべて保存はするが、使う前に 必ずオリジンへ再検証
must-revalidateすべて期限切れ(stale)後は再検証なしに返してはいけない
private共有キャッシュブラウザのみキャッシュ可。CDN は保存しない
public共有キャッシュCDN も保存可
immutableブラウザ内容が変わらない前提。再検証リクエスト自体を抑止
stale-while-revalidate=秒ブラウザ・一部 CDN期限切れ後もこの秒数は古いレスポンスを返しつつ裏で更新
stale-if-error=秒ブラウザ・一部 CDNオリジンエラー時、期限切れキャッシュをこの秒数まで返してよい

エンドポイント別のヘッダー例

# ビルド成果物(ハッシュ付きファイル名)
Cache-Control: public, max-age=31536000, immutable

# 更新頻度が低い公開 API(CDN + ブラウザ + SWR)
Cache-Control: public, max-age=300, s-maxage=600, stale-while-revalidate=60

# 認証付き・個人データ(304 で帯域節約、CDN には載せない)
Cache-Control: private, no-cache, must-revalidate

# 決済結果・個人情報
Cache-Control: private, no-store
no-cache と no-store の違い

no-store は「保存禁止」、no-cache は「保存はするが使う前に検証必須」。名前と直感が逆なので、設計レビューでは必ず curl で実例を確認する。

max-age=0no-cache は似ているが、no-cache の方が「再検証必須」の意図が明確だ。認証付き API で ETag による 304 を使うなら private, no-cache が定番になる。

リクエスト側 Cache-Control

クライアントも Cache-Control ヘッダーを送れる。Cache-Control: no-cache をリクエストに付けると、「キャッシュがあっても再検証せよ」という指示になる。ブラウザの「ハードリロード」に近い挙動だ。API クライアントが常に最新を欲しい場合は、サーバー側の no-store と組み合わせて設計する。

ETag の生成 — Strong と Weak の完全理解

ETag(Entity Tag)はリソースの バージョン識別子。内容やメタデータからサーバーが生成し、レスポンスヘッダーに付ける。クライアントは次回 If-None-Match にこの値を載せ、サーバーは一致すれば 304 を返す。

RFC 7232 では ETag を StrongWeak に分類する。選び方を誤ると、304 が返らない、Range リクエストが壊れる、圧縮方式の違いで毎回 200 になる、といった問題が起きる。

Strong ETag(強い ETag)

バイト単位で同一性を保証する。形式は "abc123" のようにダブルクォートで囲む。W/ プレフィックスは付けない。

ETag: "8f14e45fceea167a5a36dedd4bea2543"

向いているケース

  • JSON レスポンス全体の SHA-256(キーをソートして正規化したうえで)
  • ファイルのバイナリハッシュ
  • id + updated_at + revision を連結してハッシュ化
// Node.js — 決定的な Strong ETag 生成例
import { createHash } from 'node:crypto';

function strongEtag(payload) {
  const body = typeof payload === 'string' ? payload : JSON.stringify(payload);
  return `"${createHash('sha256').update(body).digest('hex').slice(0, 16)}"`;
}

// レコード更新で ETag が変わる例
function etagFromRecord(record) {
  const seed = `${record.id}:${record.updatedAt}:${record.revision}`;
  return `"${createHash('sha256').update(seed).digest('hex').slice(0, 16)}"`;
}

Strong ETag は Range リクエストRange: bytes=0-1023)と組み合わせて使える。大きなファイルの部分取得では、Weak ETag では 206 Partial Content の整合性が保証されない。

Weak ETag(弱い ETag)

意味的に同等なら一致とみなす。形式は W/"abc123" のように W/ プレフィックスを付ける。

ETag: W/"20260621-42"

向いているケース

  • 圧縮方式(gzip / br)でボディのバイト列が変わるが意味は同じ
  • フィールド順序が不定な JSON(正規化前)
  • 大きなペイロードで毎回フルハッシュが高コスト
  • 秒精度の updated_at だけで「更新があったか」を判定したい
# Python — Weak ETag(updated_at ベース)
from datetime import datetime

def weak_etag(record_id: int, updated_at: datetime) -> str:
    ts = updated_at.strftime("%Y%m%dT%H%M%SZ")
    return f'W/"{record_id}-{ts}"'
// Go — Weak ETag(リビジョン番号ベース)
func weakETag(id int64, revision int) string {
    return fmt.Sprintf(`W/"%d-r%d"`, id, revision)
}

Strong と Weak の比較表

観点StrongWeak
形式"hash"W/"hash"
一致条件バイト列が完全一致意味的に同等
Range リクエストサポート可能不可(RFC 7232)
生成コストフルボディハッシュは重いメタデータベースで軽い
API JSON正規化した JSON なら Strong 推奨非正規化・圧縮差分なら Weak
If-None-Match 照合同一 Strong 値のみ 304Weak 同士、または Strong と Weak のルールに従う

Weak ETag の照合ルール(実務でハマる点)

If-None-Match: W/"abc" を送ったとき、サーバー側の ETag が W/"abc" なら 304。Strong "abc" と Weak W/"abc" の照合は RFC 7232 のルールに従う。クライアントが送る ETag 文字列は、前回レスポンスの ETag をそのまま使う のが鉄則だ。引用符や W/ を手で削ると 304 にならない。

# Weak ETag の 304 検証
curl -sS -D - -o /dev/null \
  'https://api.example.com/v1/products/99' \
  -H 'Accept: application/json'

# レスポンス: ETag: W/"99-r7"

curl -sS -D - -o /dev/null \
  'https://api.example.com/v1/products/99' \
  -H 'Accept: application/json' \
  -H 'If-None-Match: W/"99-r7"'
# 期待: HTTP/2 304

決定的生成の重要性

複数サーバー(オリジン + ワーカー)で同じ ETag を出す必要があるなら、決定的なアルゴリズム に統一する。ランダム UUID を毎レスポンス生成すると 304 が機能しない。ロードバランサ配下では、サーバー A が "aaa"、サーバー B が "bbb" を返すと、クライアントは常に 200 になる。

Last-Modified との併用

Last-Modified はリソースの最終更新日時。If-Modified-Since と組み合わせて 304 を返せる。静的ファイルでは ETag + Last-Modified の両方を付けることが多い。

JSON API では ETag を優先 する理由は次のとおり。

  • 秒精度のため、同一秒内の連続更新を区別できない
  • DB の updated_at がアプリケーションロジックとズレることがある
  • 分散環境でサーバー間の時刻ずれが 304 判定を壊す
# Last-Modified による条件付き GET
curl -sSI \
  -H 'If-Modified-Since: Sat, 21 Jun 2026 10:00:00 GMT' \
  'https://api.example.com/v1/articles/42'

ETag と Last-Modified を両方返す場合、クライアントは通常 If-None-Match を優先 する。サーバー実装では両方の条件を評価し、どちらかが一致すれば 304 を返す実装が一般的だ。

If-None-Match と 304 Not Modified の実際のフロー

304 フローは「キャッシュに載っている ETag が、サーバー上の現行版と一致するか」を確認する仕組みだ。以下は 実際の curl 手順 で再現できる。

ステップ 1:初回 GET で ETag を取得

curl -sS -D - -o /tmp/body.json \
  'https://api.example.com/v1/articles/42' \
  -H 'Accept: application/json'

期待するレスポンスヘッダー(抜粋):

HTTP/2 200
content-type: application/json
etag: "a1b2c3d4e5f67890"
cache-control: private, max-age=0, must-revalidate
content-length: 2847

ボディは /tmp/body.json に保存される。-D - でレスポンスヘッダーを標準出力に出し、-o でボディをファイルへ分離する。

PowerShell では次のように ETag を変数に取り込める。

$headers = curl.exe -sSI 'https://api.example.com/v1/articles/42'
$etag = ($headers | Select-String '^etag:').Line.Split(':', 2)[1].Trim()
Write-Host "ETag=$etag"

ステップ 2:If-None-Match 付きで再リクエスト

前回の ETag を変数に入れて送る。引用符を含めた 完全一致 が重要だ。

ETAG='"a1b2c3d4e5f67890"'

curl -sS -D - -o /tmp/body2.json \
  'https://api.example.com/v1/articles/42' \
  -H 'Accept: application/json' \
  -H "If-None-Match: ${ETAG}"

内容が変わっていなければ 304:

HTTP/2 304
etag: "a1b2c3d4e5f67890"
cache-control: private, max-age=0, must-revalidate

304 では ボディが空/tmp/body2.json のサイズは 0 バイトになる。クライアント(ブラウザや fetch ラッパー)はローカルキャッシュのボディを使う。

内容が更新されていれば 200:

HTTP/2 200
etag: "f9e8d7c6b5a43210"
content-type: application/json
content-length: 2901

{"id":42,"title":"更新後のタイトル",...}

新しい ETag とボディが返る。以降は新 ETag で 304 判定を続ける。

ステップ 3:転送量の比較

# 200 のとき(初回)
curl -sS -w 'http_code=%{http_code} size_download=%{size_download}\n' \
  -o /dev/null 'https://api.example.com/v1/articles/42'

# 304 のとき
curl -sS -w 'http_code=%{http_code} size_download=%{size_download}\n' \
  -o /dev/null \
  -H 'If-None-Match: "a1b2c3d4e5f67890"' \
  'https://api.example.com/v1/articles/42'

304 では size_download がヘッダー分のみ(数十〜数百バイト)になり、JSON 本体(数 KB〜数 MB)の転送を省略できる。モバイル回線や高頻度ポーリング API では効果が大きい。

ステップ 4:複数 ETag の If-None-Match

If-None-Match はカンマ区切りで複数 ETag を指定できる。キャッシュに複数バージョンがある場合や、リスト取得で部分一致を試す高度な用途向けだ。

curl -sS -I \
  -H 'If-None-Match: "old-etag", "a1b2c3d4e5f67890", W/"weak-1"' \
  'https://api.example.com/v1/articles/42'

通常の単一リソース GET では、直前に受け取った ETag 1 つを送れば十分だ。

ステップ 5:CDN 経由での 304 確認

CDN が public レスポンスをエッジに載せている場合、304 は エッジから返る こともある。Age ヘッダーと X-Cache(CDN によって名称は異なる)で確認する。

# 初回 — オリジンまたは MISS
curl -sSI 'https://cdn.example.com/v1/public/categories'

# 2 回目 — HIT(Age が増えている)
curl -sSI 'https://cdn.example.com/v1/public/categories'

# 3 回目 — If-None-Match で再検証(304 または 200)
curl -sSI \
  -H 'If-None-Match: "categories-v20260621"' \
  'https://cdn.example.com/v1/public/categories'

エッジがオリジンへ再検証し、変更がなければ 304 を返す。変更があれば 200 と新 ETag でエッジキャッシュが更新される。

サーバー側の判定ロジック(概念)

// Express 風の疑似コード
app.get('/v1/articles/:id', async (req, res) => {
  const article = await db.getArticle(req.params.id);
  if (!article) return res.status(404).json({ error: 'not found' });

  const etag = strongEtagFromRecord(article);

  res.set('ETag', etag);
  res.set('Cache-Control', 'private, no-cache');

  const clientEtag = req.get('If-None-Match');
  if (clientEtag === etag) {
    return res.status(304).end(); // ボディなし
  }

  res.json(article);
});

If-None-Match: * は「その URL に何かキャッシュがあれば 304」という特殊形式。DELETE 前の存在確認など限定的な用途向けで、通常の GET キャッシュでは使わない。

304 でも認証は通す

304 を返す前に 認可チェック を省略しない。トークン失効後も古い ETag で 304 が返ると、キャッシュ済みボディが漏れる。認証失敗時は 401/403 を返し、キャッシュヘッダーを no-store にする。

stale-while-revalidate:古いキャッシュを活かす戦略

stale-while-revalidate(SWR)は、期限切れ(stale)のキャッシュを一時的に返しながら、バックグラウンドで最新版を取得する ディレクティブだ。Web の Cache-Control 拡張として RFC 5861 で定義されている。

Cache-Control: public, max-age=60, stale-while-revalidate=300

この例のタイムライン:

経過時間状態挙動
0〜60 秒freshキャッシュを即返す。オリジンへ行かない
60〜360 秒stale(SWR 内)古いキャッシュを即返す。裏でオリジンへ再検証
360 秒以降stale(SWR 外)通常の再検証フロー。待ちが発生しうる

SWR が向く API・向かない API

向く

  • ニュース一覧、ブログカテゴリ、商品カタログ
  • 設定 JSON(機能フラグ)で数秒〜数分の遅延が許容されるもの
  • ダッシュボードの参考指標(リアルタイム性が低い KPI)

向かない

  • 在庫数、座席残数、オークション現在価格
  • 決済ステータス、二要素認証のチャレンジ状態
  • 医療・金融など規制上「常に最新」が求められるデータ

stale-if-error との組み合わせ

stale-if-error=86400 を併用すると、オリジンが 500/503 を返したとき 最大 24 時間 期限切れキャッシュを配信してよい、という指示になる。公開 read-only API の可用性向上に有効だが、意図せず古い在庫データを配る リスクもある。プロダクトオーナーと許容範囲を合意してから付ける。

Cache-Control: public, max-age=120, s-maxage=300, stale-while-revalidate=60, stale-if-error=3600

フロントエンドでの利用例

async function fetchWithSWR(url, { onRevalidate } = {}) {
  const res = await fetch(url, { cache: 'default' }); // ブラウザキャッシュを利用

  const cacheControl = res.headers.get('Cache-Control') || '';
  const swrMatch = cacheControl.match(/stale-while-revalidate=(\d+)/);
  const swr = swrMatch ? Number(swrMatch[1]) : 0;

  if (res.ok && swr > 0 && onRevalidate) {
    // バックグラウンド再取得(UI は古いデータのまま表示)
    fetch(url, { cache: 'reload' }).then(onRevalidate).catch(() => {});
  }

  return res.json();
}

React Query の staleTime / gcTimeアプリ層 のキャッシュ設定であり、HTTP の stale-while-revalidate とは別物だ。両方を使う場合、どちらが先に stale 判定するかを整理しないと、想定より古いデータが表示される。

CDN での SWR 設定

Cloudflare では Cache-Controlstale-while-revalidate を解釈し、エッジでバックグラウンド再検証を行う。Fastly では stale-while-revalidate ディレクティブと VCL の stale_if_error を組み合わせる。いずれも オリジンが返すヘッダー とダッシュボードの Edge TTL が矛盾していないか確認する。

# SWR 付きレスポンスのヘッダー確認
curl -sSI 'https://api.example.com/v1/public/feed' \
  | grep -i cache-control
# cache-control: public, max-age=60, s-maxage=120, stale-while-revalidate=300

オリジンが s-maxage=600 を返しているのに CDN ダッシュボードで 86400 に固定していると、意図とズレた配信になる。Infrastructure as Code で CDN ルールをコード管理し、オリジンヘッダーをソースオブトゥルースにするのが望ましい。

CDN キャッシュキー設計

CDN は URL をキーにレスポンスをエッジに保存するが、キーの構成を誤ると別ユーザーのレスポンスが返る ことになる。API で CDN キャッシュを使うときは、次の要素を設計する。

キャッシュキーの構成要素

要素含める?備考
パス(/v1/items必須基本キー
クエリ(?page=2エンドポイント依存ページネーションは通常含める
Accept / Accept-Language内容が変わるならVary とセットで
Authorization / Cookie原則含めないユーザー固有 API は CDN キャッシュしない
カスタムヘッダー必要時のみX-Api-Version
HTTP メソッドGET のみ対象POST レスポンスはキャッシュしない

公開 GET API のキー例

# 良い例 — 全ユーザー同一レスポンス
GET /v1/public/categories
Cache-Control: public, max-age=300, s-maxage=600

CDN Cache Key: https://api.example.com/v1/public/categories
(Authorization をキーに含めない)

Cloudflare の Cache Key カスタマイズでは、クエリ文字列の 特定パラメータのみ をキーに含める設定ができる。?page=1&_t=1234567890_t を除外しないと、毎リクエストキャッシュミスになる。

ユーザー固有 API は CDN に載せない

# 悪い例 — Authorization をキーに含めて CDN キャッシュ
Cache-Control: public, max-age=60
Vary: Authorization

# 良い例 — オリジン手前で private に
Cache-Control: private, no-cache

Vary: Authorization でキーを分岐させる方法もあるが、キーの組み合わせが爆発し、設定ミスで ユーザー A の JSON がユーザー B に返る リスクが高い。個人データは private でブラウザキャッシュのみに留めるのが安全だ。

Vary ヘッダーとキー拡張

Vary は「このリクエストヘッダーの値によってレスポンスが変わる」と CDN・ブラウザに伝える。

HTTP/2 200
content-type: application/json
vary: Accept-Encoding, Accept-Language
cache-control: public, max-age=600
etag: "feed-ja-v3"

上記の場合、CDN は実質的に次のようなキー空間を持つ。

Key = URL + Accept-Encoding + Accept-Language

Vary を付け忘れると、gzip 版が plain リクエストに返る、英語版 JSON が日本語クライアントに返る、といった キャッシュポイズニング に近い事故が起きる。

クエリ文字列と正規化

?sort=name?sort=name& は CDN によって別キーになることがある。オリジンまたは CDN ルールで クエリのソート・正規化 を行う。

クエリ正規化後備考
?b=2&a=1?a=1&b=2ソートでキー統一
?page=1&utm_source=twitter?page=1トラッキングパラメータ除外
?page=1&page=2先勝ち or 400曖昧な重複は API 側で拒否

Cloudflare の「Query String Sort」、Fastly の req.url.qs.sort() など、CDN 機能で正規化する。

Cache-Tag によるパージ

大量 URL を個別パージするのは運用負荷が高い。レスポンスに Cache-Tag(Cloudflare)や Surrogate-Key(Fastly)を付け、タグ単位でパージする。

HTTP/2 200
cache-control: public, s-maxage=3600
cache-tag: articles, article-42
etag: "42-v12"

記事 42 を更新したら article-42 タグだけパージすれば、関連エンドポイント(詳細・一覧・RSS)をまとめて無効化できる。

パージ戦略

CDN キャッシュを使う以上、コンテンツ更新時のパージ をデプロイフローに含める。

  • 単一リソース更新 → その URL をパージ
  • 一括更新 → タグベースパージ(Cache-Tag ヘッダー + CDN のタグパージ)
  • 緊急時 → プレフィックスパージ(影響範囲に注意)
  • デプロイパイプライン → /* ではなく影響パスのみパージ(オリジン負荷スパイクを防ぐ)
# curl で CDN 経由のキャッシュ状態を確認(Age で鮮度推定)
curl -sSI 'https://cdn.example.com/v1/public/categories' \
  | grep -iE '^(age|cf-cache-status|x-cache|cache-control):'

キャッシュポイズニングと安全な設計

キャッシュポイズニング(Cache Poisoning)は、攻撃者またはバグが原因で、有害なレスポンスがキャッシュに載り、他ユーザーに配信される 攻撃・事故の総称だ。API 設計では次の落とし穴に注意する。

1. Vary ヘッダーの欠落

Accept-EncodingAccept-Language で内容が変わるのに Vary がないと、gzip 版と plain 版、日本語版と英語版が混在する。

# 正しい例
Content-Type: application/json
Content-Encoding: gzip
Vary: Accept-Encoding, Accept-Language
Cache-Control: public, max-age=300

2. 未使用の Host / X-Forwarded-* をキーに含めない

攻撃者が X-Forwarded-Host: evil.com を送り、レスポンスに反射されると、その Host がキーに入っている CDN が 悪意ある URL 付き JSON をキャッシュしうる。オリジンは信頼できないリクエストヘッダーをレスポンスに反映しない。CDN 側で許可ヘッダーをホワイトリスト化する。

3. エラーレスポンスのキャッシュ

500 や空ボディが public, max-age=3600 で返ると、CDN がエラーを 1 時間配信し続ける。エラー時は Cache-Control: no-store または短い max-age にする。

// エラー時はキャッシュさせない
res.status(500).set('Cache-Control', 'no-store').json({ error: 'internal' });

Set-Cookie があるレスポンスを public でキャッシュすると、セッション Cookie 付きボディが他ユーザーに渡る恐れがある。Cookie を Set するレスポンスは private, no-store が原則。

5. Web Cache Deception

/api/user/123/profile.css のように、静的拡張子付き URL で API JSON を返すと、CDN が「CSS として」長期キャッシュする設定と衝突しうる。API は /api/ プレフィックスでルーティングを分け、静的と混在させない。

6. キャッシュキーに含めるべきでないヘッダー

ヘッダーリスク
X-Forwarded-Hostホストヘッダーインジェクション経由のポイズニング
任意の X-*攻撃者が自由にキーを増やし、キャッシュを汚染
Cookie 全体セッションごとにキーが分裂し、ヒット率ゼロ + 漏洩リスク
設計レビューの観点

「このレスポンスは 誰にとって同じ内容 か?」をエンドポイントごとに書き出す。答えが「ユーザーごとに違う」なら CDN の public は禁止。curl で VaryCache-Control を本番・ステージング両方で突き合わせる。

エンドポイント別のキャッシュ戦略

リソース種別 推奨ヘッダー・備考
ハッシュ付き静的資産 public, max-age=31536000, immutable + CDN。URL 自体がバージョン
公開・更新少ない API public, max-age=300, s-maxage=600, stale-while-revalidate=60 + Strong ETag
認証付き・個人データ private, no-cache + ETag(304 で帯域節約)。CDN 非キャッシュ
在庫・決済・機密 private, no-store。ETag も付けないか、付けても no-store 優先
POST/PUT/PATCH/DELETE 原則キャッシュ不可。GET のみ Cache-Control 設計対象

Cache Busting(静的資産)

長期キャッシュしたファイルを更新するとき、同じ URL だと古い版が返り続ける。ファイル名にコンテンツハッシュapp.a1b2c3.js)を付けるのが確実だ。API の JSON に ?v=20260621 を付ける方式はキャッシュキーが増えパージが複雑になる。静的はファイル名ハッシュ、API は max-age + ETag で制御する。

条件付き GET の API 設計パターン

パターンCache-ControlETagCDN
公開マスタデータpublic, max-age=3600, s-maxage=7200Strong(内容ハッシュ)積極キャッシュ
認証付きプロフィールprivate, no-cacheStrong(id+revision)載せない
リアルタイム在庫private, no-storeなし載せない
検索結果(公開)public, max-age=60, stale-while-revalidate=120Weak(クエリ+時刻)クエリ正規化必須

実装・検証チェックリスト

1

エンドポイントごとに鮮度要件を分類する(静的 / 準静的 / 個人 / 機密)

2

Cache-Control を設計し、認証レスポンスに誤って public を付けていないか確認する

3

Strong または Weak ETag を決定的に生成し、更新時に値が変わるユニットテストを書く

4

curl -D - で ETag・Cache-Control を確認し、If-None-Match で 304・転送量ゼロを検証する

5

CDN 経由の場合、s-maxage・キャッシュキー・Vary・パージ手順をドキュメント化する

6

stale-while-revalidate の許容「古さ」をプロダクトオーナーと合意し、在庫等クリティカル API には適用しない

7

エラーレスポンスに no-store が付いているか、Set-Cookie + public の組み合わせがないか確認する

curl ワンライナー集

# ヘッダーのみ確認
curl -sS -I 'https://api.example.com/v1/articles/42'

# 初回 GET — ETag をヘッダーから目視確認
curl -sS -D - -o /dev/null 'https://api.example.com/v1/articles/42'

# 304 確認(ステータス行のみ)
curl -sS -I -H 'If-None-Match: "a1b2c3d4e5f67890"' \
  'https://api.example.com/v1/articles/42' | head -n 1

# Weak ETag で 304 確認
curl -sS -I -H 'If-None-Match: W/"99-r7"' \
  'https://api.example.com/v1/products/99' | head -n 1

# Cache-Control / Vary / Age の一括確認
curl -sSI 'https://api.example.com/v1/public/categories' \
  | grep -iE '^(cache-control|etag|vary|age):'

# 転送バイト比較(200 vs 304)
curl -sS -w 'code=%{http_code} bytes=%{size_download}\n' -o /dev/null \
  'https://api.example.com/v1/articles/42'
curl -sS -w 'code=%{http_code} bytes=%{size_download}\n' -o /dev/null \
  -H 'If-None-Match: "a1b2c3d4e5f67890"' \
  'https://api.example.com/v1/articles/42'

# CDN キャッシュヒット確認
curl -sSI 'https://cdn.example.com/v1/public/categories' \
  | grep -iE '^(age|cf-cache-status|x-cache):'

DevTools の Network タブでは、304 レスポンスは Size 列が (disk cache)(memory cache) と表示され、Transferred がヘッダー分のみになる。本番とステージングでヘッダーが一致しているかもデプロイ前に確認する。

ユニットテストで押さえる ETag テスト

import { describe, it, expect } from 'vitest';
import { etagFromRecord } from './etag';

describe('etagFromRecord', () => {
  it('同じレコードなら同じ ETag', () => {
    const r = { id: 1, updatedAt: '2026-06-21T10:00:00Z', revision: 3 };
    expect(etagFromRecord(r)).toBe(etagFromRecord({ ...r }));
  });

  it('revision が変われば ETag も変わる', () => {
    const base = { id: 1, updatedAt: '2026-06-21T10:00:00Z', revision: 3 };
    const updated = { ...base, revision: 4 };
    expect(etagFromRecord(base)).not.toBe(etagFromRecord(updated));
  });
});

関連記事

この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
Web APIのエラーハンドリング設計Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け
Web APIのべき等性(Idempotency)設計Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド
API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較
APIのレート制限(Rate Limiting)実装ガイドAPIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング
APIレート制限と429対応APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装
WebHook設計のベストプラクティスWebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する
ZodでAPI境界のランタイム検証を設計するZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026)
OpenAPI 3.1仕様のCIバリデーション完全ガイドOpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions
GraphQL N+1 問題の解決GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド
Circuit Breaker + Retry パターン実装ガイドCircuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter
Sagaパターン完全ガイドSagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js)
Event SourcingとCQRSの基礎Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド

まとめ

API キャッシュの設計は、Cache-Control で層と寿命を決めETag + If-None-Match + 304 で無駄な転送を削りstale-while-revalidate で体感速度を上げCDN キャッシュキーと Vary でポイズニングを防ぐ —— この 4 点をセットで考える。

Strong ETag は正規化 JSON や Range 向け、Weak ETag は圧縮差分やメタデータベース更新向け。curl で If-None-Match を送り 304 と転送量ゼロを確認する習慣が、設計と実装のズレを早期に見つける。

最初の一歩は、curl で現状の ETag / Cache-Control / Vary を洗い出し、エンドポイント単位の 鮮度要件表 を作ることだ。そこから Strong ETag の生成方式と CDN の s-maxage を決めれば、帯域・TTFB・オリジン負荷を安全に下げられる。