stale-while-revalidateは、期限切れのキャッシュをそのまま返しつつ、裏側で新しい応答を取り直すためのキャッシュ制御です。HTTPのCache-Controlディレクティブとしての顔と、Reactのデータ取得ライブラリSWRが実装する再検証の顔があり、同じ名前でも設定場所も効き方も異なります。この記事では両方を一次情報で切り分け、CDNごとに割れる設定判断まで整理します。
まとめ
stale-while-revalidateはRFC 5861(2010年5月・Informational)が定めるCache-Control拡張で、「応答がstaleになった後も、指定秒数まではキャッシュがその応答を提供してよい」という指示です。ブラウザはChrome 75・Firefox 68・Safari 14から解釈します。実務で迷いやすいのはs-maxageとの併用で、Cloudflareは「併用するな」、Vercelは「Functionsではs-maxageを付けろ」と正反対の指示を出しています。配信先のドキュメントを正として書き分けてください。ReactのSWR(2.5.1)は同じ考え方をクライアント側で実装したもので、revalidateIfStaleやdedupingIntervalなどの既定値がその挙動を決めます。以下、定義・対応状況・CDNごとの差・ライブラリ側の設定の順に見ていきます。
stale-while-revalidateの意味とRFC 5861の定義
RFC 5861が定める2つの拡張ディレクティブ
RFC 5861「HTTP Cache-Control Extensions for Stale Content」は、Mark Nottingham氏が2010年5月にIndependent Submissionとして発行したInformational RFCです。ここで定義されているのは3節のstale-while-revalidateと4節のstale-if-errorの2つで、前者は「応答がstaleになった後も、指定された秒数まではキャッシュがその応答を提供してよい」、後者は「エラーが発生したとき、他の鮮度情報にかかわらずキャッシュ済みのstale応答を要求への応答に使用してよい」と規定します。どちらも値はdelta-seconds(秒数)です。
revalidateは「再検証」、staleは「期限切れ」を指します。つまりstale-while-revalidateは、期限切れの応答を返すことでオリジンへの再検証完了を待たずに応答し、裏側で再検証を試みるための指示です。再検証が完了するまでは、許容期間内の後続アクセスにも古い応答が返る場合があります。再検証が成功すると、以後は更新済み、または内容が変わっていないと確認された応答が返ります。
max-ageと組み合わせたときの3つの時間帯
このディレクティブ自体は鮮度の期間を持ちません。鮮度はExpiresやヒューリスティックでも決まりますが、実務ではmax-age(またはs-maxage)と組み合わせ、鮮度の期間と、期限切れ後に古い応答を許す期間を分けて明示します。
Cache-Control: max-age=3600, stale-while-revalidate=600, stale-if-error=86400
Amazon CloudFrontの公式ドキュメントはこの指定を例示し、最初の1時間は通常のキャッシュヒット、経過後の10分間は古い応答を即返しながら裏でオリジンへ再検証、その再検証でオリジンがエラーを返した場合は24時間まで古い応答を返し続ける、と説明しています。注意点として、CloudFrontが古い応答を返せるのはstale-while-revalidateの値とCloudFrontの最大TTLのうち小さい方までで、最大TTLを過ぎたオブジェクトはエッジキャッシュから消えます。Amazon CloudFrontとは?仕組み・料金プランとエッジ機能・採用判断を実装者目線で解説でTTL設定の全体像を確認しておくと、この上限の意味がつかみやすくなります。
ブラウザとCDNの対応状況
ブラウザの対応バージョン
MDNのブラウザ互換データによると、応答ディレクティブとしてのstale-while-revalidateはChrome 75、Firefox 68、Safari 14から対応しています。いずれも2019年から2020年にかけてのリリースで、現行のブラウザであれば解釈されると考えて差し支えありません。一方stale-if-errorには同じ互換データの項目がなく、ブラウザ側の実装を前提にした設計は避けるべきです。エラー時のフォールバックはCDNかService Workerの層で持たせます。
CDNごとの挙動の違い
| 配信基盤 | stale-while-revalidate | stale-if-error | 注意点 |
|---|---|---|---|
| Amazon CloudFront | 対応 | 対応(500〜600の応答) | 最大TTLとの小さい方で頭打ち |
| Cloudflare | 対応 | 対応 | Always Online有効時は両方とも無視 |
| Vercel | 対応(s-maxageと併記) | 公式内で記述が不一致 | Limitsは未対応と明記 |
| Fastly | 対応 | 対応 | Surrogate-Controlはs-maxage非対応 |
同じヘッダーを返しても、どこまで古い応答を配るかは基盤側の設定に左右されます。CloudflareではAlways Onlineを有効にしているとstale-while-revalidateとstale-if-errorの指定が無視されます。さらにCloudflareのドキュメントは、max-age・s-maxage・stale-while-revalidateの値はRFC 9111に従って整数でなければならず、max-age=2.5のような小数は無効として扱われ、キャッシュそのものを素通りする可能性があると明記しています。Workersのcache.matchやcache.putを経由する場合もこのディレクティブは効きません。
Fastlyでは、stale-while-revalidateとstale-if-errorを同時に書いた場合、どちらの期間もmax-ageが切れた同じ瞬間から数え始めます。段階的にずれていくわけではないため、公式が推奨する組み合わせは「stale-while-revalidateは短く、stale-if-errorは長く」です。オリジンが生きているときに極端に古い内容を配らず、落ちているときだけ長く耐える形になります。ブラウザには短い鮮度を、CDNには長いstale許容を与えたい場合はSurrogate-Controlを使いますが、Fastlyのこのヘッダーはs-maxageを解釈しない点に注意してください。
s-maxageとの併用条件とCDNごとの差
配信基盤を移行するときは、s-maxageとstale-while-revalidateの併用条件を確認してください。Cloudflareのドキュメントは「s-maxageをstale-while-revalidateと併用しないでください」と注意し、理由としてRFC 9111でs-maxageがproxy-revalidateの意味を含むため、共有キャッシュがオリジンに再検証せずstaleな応答を配ることができなくなる点を挙げています。Cloudflare配下ではmax-age=600, stale-while-revalidate=30のようにmax-ageと組み合わせるのが公式の例です。
ところがVercelは逆です。VercelのCDNでFunctionsの応答をキャッシュさせるにはs-maxage=Nを含める必要があり、公式に提示されている組み合わせはs-maxage=N, stale-while-revalidate=Zおよびs-maxage=N, stale-while-revalidate=Z, stale-if-error=Zです。proxy-revalidateは現時点で未対応と明記されており、Cloudflareが警戒している意味論をそもそも実装していません。
ただしstale-if-errorの扱いは同じページ内で食い違っています。Functionsの節ではs-maxage=N, stale-while-revalidate=Z, stale-if-error=Zが使える組み合わせとして挙げられている一方、同じページのLimitsの節にはproxy-revalidateとstale-if-errorはサーバーサイドのキャッシュでは未対応と書かれています(2026年9月14日更新時点)。Vercelでエラー時のstale配信を当てにする設計は、対象環境で応答ヘッダーとキャッシュ状態を確認してから決めてください。
// Vercel公式が示すRoute Handlerの例
export async function GET() {
return new Response('Cache Control example', {
status: 200,
headers: { 'Cache-Control': 's-maxage=1, stale-while-revalidate=59' }
})
}
この食い違いはフレームワーク側の出力にも影響します。Next.jsのISRはrevalidateの秒数とexpireTimeからCache-Controlを組み立てる仕様で、公式ドキュメントはexpireTimeを1時間、対象パスのrevalidateを15分にした場合に生成される値をs-maxage=900, stale-while-revalidate=2700と明記しています。Vercel上では想定どおり動く一方、同じ成果物をCloudflare配下に置くとs-maxageが併記されているためstale配信が無効になり得ます。この場合に直接変わるのはCloudflareでのstale配信であり、Next.jsサーバー内のISR再生成が無効になるわけではありません。
つまり「s-maxageとstale-while-revalidateを併用すべきか」に一般解はありません。RFCの意味論を厳密に適用する基盤では併用が無効化され、Vercelのようにs-maxageをキャッシュ判定の入口にしている基盤では併用が前提になります。判断基準は配信先のドキュメントであり、他社の設定例をそのまま持ち込むと、保存自体は行われても期限切れ後のstale配信が無効になり、再検証待ちが発生する場合があります。移行時には必ず実際の応答ヘッダーと、Vercelであればx-vercel-cacheのようなキャッシュ状態を示すヘッダーで結果を確認してください。
もう1つVercel固有の落とし穴があります。CDN-Cache-Controlを設定せずにCache-Controlだけを返すと、VercelのCDNはブラウザへ送る前にs-maxageとstale-while-revalidateを取り除きます。ブラウザキャッシュにも同じ挙動をさせたい場合は、CDN向けとブラウザ向けのヘッダーを分けて指定する必要があります。Next.jsを使っているなら、ページ側のキャッシュ制御はNext.jsのキャッシュ|5つの保存先とuse cache・再検証APIの使い分けと合わせて設計すると重複した指定を避けられます。
ReactのSWRにおける再検証の既定値と状態管理
再検証のトリガーと既定値
Vercelが公開しているReact向けデータ取得ライブラリSWRは、この戦略の名前をそのまま冠しています。最新版は2.5.1(2026年8月12日公開・MITライセンス・peerDependenciesはreact ^16.11/^17/^18/^19)です。HTTPヘッダーがCDNやブラウザのキャッシュに効くのに対し、SWRが制御するのはJavaScriptのメモリ上のキャッシュで、設定場所も効く範囲も別物です。
再検証がいつ走るかは、SWR 2.5.1のソースsrc/_internal/utils/config.tsにあるdefaultConfigの既定値で決まります。
| オプション | 既定値 | 役割 |
|---|---|---|
| revalidateIfStale | true | キャッシュがある状態でマウントしても再検証 |
| revalidateOnFocus | true | タブがフォーカスされたら再検証 |
| revalidateOnReconnect | true | ネットワーク復帰で再検証 |
| dedupingInterval | 2000ms | 同一キーの重複リクエストを束ねる |
| focusThrottleInterval | 5000ms | フォーカス再検証の間隔 |
| errorRetryInterval | 5000ms | エラー時の再試行の基準間隔 |
| loadingTimeout | 3000ms | onLoadingSlowが発火するまで |
| shouldRetryOnError | true | エラー時に再試行するか |
この表で見落とされやすいのがrevalidateIfStaleです。通常の構成では、既定のtrueによりキャッシュがあってもマウント時の再検証が有効になります。ただし、明示したrevalidateOnMountが優先され、リクエストの重複排除なども適用されます。falseにするとキャッシュがある間は再取得せず、mutateやフォーカスなど別のトリガーがない限り古い値を返し続けます。更新頻度に加え、再マウント時の再取得が必要か、mutateなど別の更新経路を設けているかで判断します。
なおerrorRetryIntervalとloadingTimeoutは回線速度で切り替わります。navigator.connection.effectiveTypeがslow-2gか2g、またはsaveDataが真のとき、それぞれ10,000msと5,000msになります。開発機では再現しにくい挙動なので、低速回線の検証時は数値が変わる前提で読んでください。
isLoadingとisValidatingの使い分け
ローディング表示でつまずくのはこの2つの違いです。isLoadingは現在のキーについて取得済みデータがなく、リクエストが進行中のときにtrueになります。キー変更後の取得や初回取得失敗後の再試行でもtrueになり得て、fallbackDataやkeepPreviousDataによる表示値は取得済みデータに含みません。isValidatingは初回ロードやfetcherによる再検証中にtrueになります。Promiseを渡したmutateの更新処理を待っている間は、それだけではtrueになりません。
import useSWR from 'swr'
const fetcher = async (url) => {
const res = await fetch(url)
if (!res.ok) {
const error = new Error(`HTTP ${res.status}`)
error.status = res.status
throw error
}
return res.json()
}
function Profile() {
const { data, error, isLoading, isValidating } = useSWR('/api/user', fetcher)
if (error) return <p>読み込みに失敗しました</p>
if (isLoading) return <p>読み込み中</p>
// dataは表示したまま、再検証中だけ控えめな印を出す
return (
<div>
{data.name}
{isValidating && <span>更新中</span>}
</div>
)
}
スケルトン表示の分岐はisLoadingで行い、isValidatingは既に表示している内容の上に小さな更新中表示を重ねる用途に向きます。isValidatingでスケルトンに切り替えると、フォーカスのたびに画面が消えることになります。
mutateによる手動再検証とキーの共有
ユーザーの操作直後など、トリガーを待たずに更新したい場面ではmutateを使います。同じキャッシュプロバイダー内の同一キーを持つフックはキャッシュを共有し、再検証結果が各フックに反映されます。以下のimportしたmutateは既定のキャッシュが対象で、独自プロバイダー内ではuseSWRConfigからmutateを取得します。
import useSWR, { mutate } from 'swr'
// 同じキーなら別コンポーネントからでも再検証できる
await mutate('/api/user')
// 楽観的更新: 先に新しい値を描画し、裏で再検証する
await mutate('/api/user', updateUser(newName), {
optimisticData: { name: newName },
revalidate: true
})
キーの共有は重複リクエストの抑制にも効きます。同じキーのフックを複数のコンポーネントで同時にマウントしても、dedupingIntervalの2秒以内であればfetcherの呼び出しは1回にまとまります。再検証中も表示は直前の値のまま維持され、取得完了後に差し替わります。これがライブラリ側で再現されたstale-while-revalidateの動きです。useSWRそのものの構文やオプション設定はuseSWR(SWR)とは?Reactのデータ取得をfetcher・オプション・mutateで解説で扱っています。
エラー時の扱いと再試行の設計
オリジンが落ちたときに古い応答で耐える役割は、前述のとおりCDN側のstale-if-errorが担います。ブラウザ実装は期待できないため、この保険はCDNかオリジン前段に置く設計になります。
クライアント側のSWRは別の方式で耐えます。shouldRetryOnErrorが既定でtrueのため、fetcherが失敗すると指数バックオフで再試行します。待ち時間はMath.floor((Math.random() + 0.5) * 2 ** Math.min(リトライ回数, 8)) * errorRetryIntervalミリ秒で、指数部分はリトライ8回で頭打ちになります。注意すべきはerrorRetryCountに数値の既定値がない点で、指定しない限り再試行の上限がありません。障害中のAPIに対して無限に叩き続けないよう、認証エラーなど回復しないステータスではshouldRetryOnErrorを落とすかerrorRetryCountを明示してください。
キャッシュ戦略そのものをより細かく制御したい場合は、クエリキャッシュの設計が異なるTanStack Queryとは?React Queryとの違い・v5の使い方と脆弱性対策も比較対象になります。
よくある質問
stale-while-revalidateとはどういう意味ですか?
「期限切れ(stale)の応答を返しながら、その裏で再検証(revalidate)する」という意味です。RFC 5861が定めるCache-Control拡張で、指定した秒数のあいだは期限切れのキャッシュを即座に返すことが許されます。待ち時間を削る代わりに、一時的に古いデータが表示されることを受け入れる設定です。
revalidateIfStaleをfalseにすると何が変わりますか?
revalidateOnMountを明示していない通常の構成では、キャッシュに値がある場合のマウント時の再検証を抑制します(revalidateOnMountを明示した場合はそちらが優先されます)。既定のtrueではマウントのたびに裏で取り直しますが、falseにするとフォーカスやネットワーク復帰、mutateなど別のトリガーがない限り古い値のままです。更新頻度が低いデータや、mutateなど別の更新経路で鮮度を管理する設計で、マウント時の通信を減らしたい場合に使います。
SWRのrefreshIntervalとstale-while-revalidateはどう違いますか?
refreshIntervalは指定した間隔で定期的にポーリングする設定で、既定では無効です。stale-while-revalidateは時間で定期実行するのではなく、アクセスやマウントといったイベントを契機に「古い値を返しつつ取り直す」挙動を指します。一定間隔で更新したい画面ではrefreshIntervalを併用できます。ただしポーリング間隔中の変更は即時には反映されず、常時最新を保証する設定ではありません。
stale-if-errorはブラウザでも効きますか?
MDNのブラウザ互換データにstale-if-errorの項目はなく、ブラウザ側の対応を前提にはできません。一方CloudFrontとCloudflareは対応しているため、オリジン障害時のフォールバックはCDN層で設定します。Vercelは公式ドキュメント内で対応可否の記述が割れているので、実環境での確認が必要です。
Next.jsのISRとstale-while-revalidateは同じものですか?
考え方は同じですが、動く場所が違います。ISRは生成済みページをサーバー側で再生成する仕組みで、Cache-Controlディレクティブとしてのstale-while-revalidateはCDNやブラウザのキャッシュに対する指示です。ただし両者は無関係ではなく、ISRはs-maxageとstale-while-revalidateを含むCache-Controlを出力してCDNに古い応答を配らせます。詳しくはNext.jsのレンダリング方式|SSG・ISR・SSR・CSRの違いとApp Routerでの選び方を参照してください。