



WithCodeMedia-1-pc
WithCodeMedia-2-pc
WithCodeMedia-3-pc
WithCodeMedia-4-pc




WithCodeMedia-1-sp
WithCodeMedia-2-sp
WithCodeMedia-3-sp
WithCodeMedia-4-sp









生徒サイトを更新したのにキャッシュが残っていてユーザーに古いページが表示されたり、逆に毎回全部読み込んで遅かったりします……ブラウザキャッシュって難しいですね



ブラウザキャッシュの仕組みを正しく理解すれば、「変更があれば最新版を取得、なければキャッシュを使う」という理想的な動作を実現できるんじゃ!ETagとCache-Controlを使い分けて、パフォーマンスとコンテンツの鮮度を両立させよう!さらにCDNキャッシュ・stale-while-revalidate・Nginx/Next.jsの実装まで徹底解説するぞ!
ブラウザキャッシュの最適解は「静的アセットは1年間immutableキャッシュ・HTMLはETagで毎回確認・機密データはno-store」という3原則です。これを押さえると、2回目以降のアクセスで転送量を99%以上削減しながら常に最新コンテンツを届けられます。
本記事では、ブラウザキャッシュの4つのHTTPヘッダー(ETag・Last-Modified・Cache-Control・Expires)の仕組みと使い分け・Cache-Control全ディレクティブ詳解・stale-while-revalidate・条件付きリクエストの動作フロー・CDNキャッシュとVaryヘッダー・Cache-Busting戦略・Node.js/Express/Hono/Nginx/Next.js/Vercelでの実装例・DevToolsデバッグ方法を完全解説します。
| ヘッダー | 種類 | 動作 | 特徴 | 推奨用途 |
|---|---|---|---|---|
| Expires | 強制キャッシュ | 期限内はHTTPリクエストを送らない | 絶対日付指定・サーバー時刻依存(旧式) | 後方互換性のみ |
| Cache-Control | 強制キャッシュ | max-age秒の間はHTTPリクエストを送らない | 複数ディレクティブで柔軟に設定 | 全リソース |
| Last-Modified | 条件付きキャッシュ | If-Modified-Since で変更確認後キャッシュ or 取得 | 秒単位の精度・ファイルシステムの更新日時 | シンプルな静的サーバー |
| ETag | 条件付きキャッシュ | If-None-Match でハッシュ照合後キャッシュ or 取得 | 高精度(コンテンツハッシュベース) | 動的コンテンツ・API |
>【強制キャッシュ vs 条件付きキャッシュの違い】
強制キャッシュ(Cache-Control: max-age=3600):
1. ブラウザがキャッシュの有効期限を確認
2. 有効期限内 → サーバーに一切通信しない(ネットワーク0リクエスト)
3. 有効期限切れ → 条件付きリクエスト or 通常リクエスト
条件付きキャッシュ(ETag + Cache-Control: no-cache):
1. 毎回サーバーにリクエスト(但し If-None-Match ヘッダー付き)
2. サーバーがETagを比較
3. 変更なし → 304(ボディなし、数十バイトのレスポンス)
4. 変更あり → 200 + 新しいコンテンツ
→ 静的アセット:強制キャッシュ(max-age=1年)が最高パフォーマンス
→ HTML・API:条件付きキャッシュ(no-cache + ETag)で鮮度を保つ>【ETagの動作フロー詳細】
━━━ 初回アクセス ━━━
ブラウザ → GET /api/products HTTP/1.1
Host: example.com
Accept: application/json
サーバー → HTTP/1.1 200 OK
Content-Type: application/json
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Cache-Control: no-cache
Content-Length: 1234
{"products": [...]}
ブラウザ:レスポンスとETag値をキャッシュに保存
━━━ 2回目のアクセス(条件付きGET)━━━
ブラウザ → GET /api/products HTTP/1.1
Host: example.com
If-None-Match: "d41d8cd98f00b204e9800998ecf8427e"
サーバー(変更なし) → HTTP/1.1 304 Not Modified
ETag: "d41d8cd98f00b204e9800998ecf8427e"
(ボディなし → 数十バイトのみ転送)
ブラウザ:キャッシュから以前のレスポンスを使用
━━━ 3回目のアクセス(コンテンツ変更後)━━━
ブラウザ → GET /api/products HTTP/1.1
If-None-Match: "d41d8cd98f00b204e9800998ecf8427e" ← 古いETag
サーバー(変更あり) → HTTP/1.1 200 OK
ETag: "new_hash_value_xyz" ← 新しいETag
Content-Length: 2345
{"products": [...updated...]}
ブラウザ:新しいコンテンツと新しいETagでキャッシュを更新>【Strong ETag vs Weak ETag】
Strong ETag(強いETag):
ETag: "abc123def456"
→ バイト単位で完全一致の場合のみ同一とみなす
→ Range リクエスト(部分取得)にも使用可能
→ 推奨:精度が高く信頼性が高い
Weak ETag(弱いETag):
ETag: W/"abc123def456"
→ セマンティックに同等であれば同一とみなす
→ 例:HTTP vs HTTPS、gzip圧縮有無の違いを無視
→ 用途:コンテンツは同じだが表現が異なる場合
【ETagの生成方法例】
// ファイルの内容ハッシュ(推奨)
const etag = crypto.createHash('sha256').update(fileContent).digest('hex')
// → "e3b0c44298fc1c149afbf4c8996fb924..."
// ファイルサイズ + 最終更新時刻(Nginx/Apacheのデフォルト)
const etag = `"${fileSize.toString(16)}-${mtime.toString(16)}"`
// → "1a2b-5d3c4e8f9a"
// バージョン番号(APIレスポンス向け)
const etag = `"v${version}-${lastModifiedTimestamp}"`
// → "v42-1710000000000"| ディレクティブ | 値 | 説明 | 対象 |
|---|---|---|---|
max-age | 秒数 | 指定秒数の間は再リクエストしない(Expiresより優先) | ブラウザ・CDN |
s-maxage | 秒数 | 共有キャッシュ(CDN)の有効期限(max-ageより優先) | CDNのみ |
no-cache | – | キャッシュは保存するが毎回サーバーに確認(ETagと組み合わせ) | ブラウザ・CDN |
no-store | – | キャッシュを一切保存しない(最も厳格) | ブラウザ・CDN |
public | – | ブラウザ・CDN両方にキャッシュを許可 | CDN |
private | – | ブラウザのみキャッシュ可(CDNはキャッシュ不可) | CDN除外 |
immutable | – | max-age内でも再確認リクエストを送らない(Firefox/Chrome対応) | ブラウザ |
stale-while-revalidate | 秒数 | max-age後もこの秒数間はキャッシュを返しながらバックグラウンドで更新 | ブラウザ・CDN |
stale-if-error | 秒数 | サーバーエラー時にこの秒数間はキャッシュを返す | CDN |
must-revalidate | – | 期限切れキャッシュはサーバー確認が必須 | ブラウザ |
stale-while-revalidate は、キャッシュが期限切れでもユーザーに即座に古いデータを返しながら、バックグラウンドで新しいデータを取得するディレクティブです。「速さ」と「鮮度」を両立できる強力な戦略です。
>【stale-while-revalidate の動作フロー】
Cache-Control: max-age=60, stale-while-revalidate=3600
→ 最初の60秒:キャッシュから即座に返す(リクエストなし)
→ 61秒〜3660秒:キャッシュから古いデータを返す + バックグラウンドで更新
→ 3661秒以降:通常のキャッシュミス(サーバーに同期リクエスト)
【使用例(1時間ごとに変わるニュースサイト)】
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
→ 1時間以内:キャッシュから即座に返す
→ 1時間〜24時間:古いキャッシュを返しながらバックグラウンドで最新取得
→ ユーザーは常に高速なレスポンスを体験できる>// Hono(Node.js/Edge Runtime対応)でのstale-while-revalidate実装
import { Hono } from 'hono'
import { cache } from 'hono/cache'
const app = new Hono()
// APIレスポンスにキャッシュヘッダーを設定
app.get('/api/news', async (c) => {
const data = await fetchLatestNews()
return c.json(data, 200, {
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
'ETag': generateEtag(data),
'Last-Modified': new Date().toUTCString(),
'Vary': 'Accept-Language', // 言語別にキャッシュを分ける
})
})
// 条件付きリクエストの処理
app.get('/api/products', async (c) => {
const data = await fetchProducts()
const json = JSON.stringify(data)
const etag = `"${await generateHash(json)}"`
// If-None-Match チェック
const clientEtag = c.req.header('if-none-match')
if (clientEtag === etag) {
return new Response(null, {
status: 304,
headers: { 'ETag': etag }
})
}
// If-Modified-Since チェック(フォールバック)
const lastModified = new Date('2026-03-14').toUTCString()
const ifModifiedSince = c.req.header('if-modified-since')
if (ifModifiedSince && new Date(ifModifiedSince) >= new Date(lastModified)) {
return new Response(null, { status: 304 })
}
return c.json(data, 200, {
'ETag': etag,
'Last-Modified': lastModified,
'Cache-Control': 'no-cache',
})
})
export default app>【リソース種別ごとの推奨キャッシュ設定パターン】
━━━ パターン1:静的アセット(ハッシュ付きファイル名)━━━
対象:main.a1b2c3d4.js, styles.e5f6g7h8.css, image.webp
設定:Cache-Control: public, max-age=31536000, immutable
理由:URLにハッシュが含まれるため、コンテンツ変更時はURLが変わる
→ 同じURLは永遠に同じコンテンツ(immutableで確認リクエストも省略)
━━━ パターン2:HTMLドキュメント ━━━
対象:index.html, /about, /blog/post-1
設定:Cache-Control: no-cache
ETag: "abc123"
理由:ユーザーが常に最新のHTMLを見る必要がある
→ 毎回確認するが変更がなければ304(高速)
━━━ パターン3:APIレスポンス(頻繁に変わるデータ)━━━
対象:/api/cart, /api/user, /api/notifications
設定:Cache-Control: no-store
理由:ユーザー固有・リアルタイムデータは絶対にキャッシュしてはいけない
━━━ パターン4:APIレスポンス(変化が少ないデータ)━━━
対象:/api/products, /api/categories
設定:Cache-Control: public, max-age=300, stale-while-revalidate=3600
ETag: "xyz789"
理由:5分のキャッシュで多くのリクエストをオフロード
SWRで期限切れ後もUIはサクサク動作
━━━ パターン5:認証済みページ ━━━
対象:/dashboard, /profile, /orders
設定:Cache-Control: private, no-cache
ETag: "user123-content-hash"
理由:private でCDNにキャッシュさせない
no-cache + ETag で毎回確認(personalized content)
━━━ パターン6:画像・動画(CDN配信)━━━
対象:/images/hero.jpg, /uploads/video.mp4
設定:Cache-Control: public, max-age=604800, stale-while-revalidate=86400
理由:画像は1週間キャッシュ。変更時はURLを変更してキャッシュバスト>// Express での ETag + Cache-Control 実装
import express from 'express'
import { createHash } from 'crypto'
import { readFileSync, statSync } from 'fs'
const app = express()
// Expressはデフォルトで etag: 'weak' が有効
// Strong ETAGに変更
app.set('etag', 'strong')
// 静的ファイルに最適なキャッシュヘッダーを付与
app.use('/static', express.static('public', {
etag: true,
lastModified: true,
// ハッシュ付きファイル名の場合は1年キャッシュ
setHeaders: (res, path) => {
if (path.match(/\.[a-f0-9]{8,}\.(js|css|png|jpg|webp)$/)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else {
res.setHeader('Cache-Control', 'public, max-age=86400')
}
},
}))
// 動的APIエンドポイントでETagを手動設定
app.get('/api/products', async (req, res) => {
const products = await db.getProducts()
const json = JSON.stringify(products)
// コンテンツのSHA-256ハッシュからETagを生成
const hash = createHash('sha256').update(json).digest('hex')
const etag = `"${hash}"`
// If-None-Match ヘッダーと照合
const clientEtag = req.get('if-none-match')
if (clientEtag === etag) {
return res.status(304).set({
'ETag': etag,
'Cache-Control': 'no-cache',
}).end()
}
// Last-Modified の確認
const lastModified = new Date(products[0]?.updatedAt || Date.now()).toUTCString()
const ifModifiedSince = req.get('if-modified-since')
if (ifModifiedSince && new Date(ifModifiedSince) >= new Date(lastModified)) {
return res.status(304).end()
}
res
.set('ETag', etag)
.set('Last-Modified', lastModified)
.set('Cache-Control', 'no-cache')
.set('Vary', 'Accept-Encoding')
.json(products)
})>// app/api/products/route.ts - Next.js App Router
import { NextRequest, NextResponse } from 'next/server'
import { createHash } from 'crypto'
export async function GET(request: NextRequest) {
const products = await fetchProducts()
const json = JSON.stringify(products)
const etag = `"${createHash('sha256').update(json).digest('hex')}"`
// 条件付きリクエストの処理
const ifNoneMatch = request.headers.get('if-none-match')
if (ifNoneMatch === etag) {
return new NextResponse(null, {
status: 304,
headers: { 'ETag': etag }
})
}
return NextResponse.json(products, {
headers: {
'ETag': etag,
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
}
})
}
// next.config.js - 静的ファイルのキャッシュヘッダー設定
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
// ハッシュ付き静的ファイルは永久キャッシュ
source: '/_next/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
// HTMLページはno-cache(ETagで確認)
source: '/((?!_next).*)',
headers: [
{
key: 'Cache-Control',
value: 'no-cache',
},
],
},
]
},
}
module.exports = nextConfig># /etc/nginx/conf.d/app.conf
# Nginxの完全なキャッシュ設定例
server {
listen 443 ssl http2;
server_name example.com;
# ETag を有効化(Nginxはデフォルトで有効)
etag on;
# ハッシュ付き静的アセット(JS・CSS・フォント)は1年キャッシュ
location ~* \.(js|css|woff2?|ttf|otf)$ {
# ハッシュが含まれているURLパターン(例: main.a1b2c3.js)
if ($uri ~* "\.[a-f0-9]{8,}\.") {
add_header Cache-Control "public, max-age=31536000, immutable";
break;
}
# ハッシュがない場合は1日キャッシュ
add_header Cache-Control "public, max-age=86400";
}
# 画像は1週間キャッシュ
location ~* \.(png|jpg|jpeg|gif|webp|avif|svg|ico)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800, stale-while-revalidate=86400";
}
# HTMLは no-cache(ETagで毎回確認)
location ~* \.html$ {
add_header Cache-Control "no-cache";
# ETagはNginxが自動で付与する
}
# ルートと動的ルートはno-cache
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
# APIはキャッシュしない
location /api/ {
add_header Cache-Control "no-store";
add_header Pragma "no-cache";
proxy_pass http://backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# バックエンドのETagをそのままパススルー
proxy_pass_header ETag;
proxy_pass_header Last-Modified;
}
}
# キャッシュの圧縮設定(gzip/brotli)
gzip on;
gzip_vary on; # Vary: Accept-Encoding ヘッダーを追加(CDN対応)
gzip_types text/plain application/json application/javascript text/css;CDN(Cloudflare, CloudFront, Fastly等)は独自のキャッシュレイヤーを持っています。VaryヘッダーはCDNがキャッシュを分けるための判断基準として機能します。
| Vary値 | 用途 | 注意点 |
|---|---|---|
Accept-Encoding | gzip/brotli圧縮有無でキャッシュ分離 | ほぼ必須。設定漏れで圧縮済みコンテンツを非対応ブラウザに返す可能性 |
Accept-Language | 言語別キャッシュ分離 | 多言語サイトで同一URLが言語によって内容が変わる場合 |
Cookie | ユーザー別キャッシュ分離 | CDNには非推奨。全Cookieパターンをキャッシュしてヒット率が激減する |
Authorization | 認証トークン別キャッシュ | CDNには非推奨。private設定と組み合わせて使う |
>【CDNキャッシュの設定例(Cloudflare)】
# Cloudflare Page Rule または Cache Rules
# *.js, *.css → Cache Everything, Edge TTL: 1 year
# /api/* → Bypass Cache
# Cache-Control の s-maxage でCDNのみの有効期限を設定
Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=300
→ ブラウザ:60秒キャッシュ
→ CDN:1時間キャッシュ
→ CDNキャッシュ期限後:古いデータを返しつつ5分間バックグラウンド更新| 方法 | 例 | メリット | デメリット |
|---|---|---|---|
| ファイル名にハッシュ(推奨) | main.a1b2c3.js → main.e5f6g7.js | URLが変わるので確実。CDNも自動対応 | ビルドツールが必要(Vite/webpack) |
| クエリパラメーター | style.css?v=1.2.3 | 実装が簡単 | CDNがクエリを無視する場合がある |
| CDNキャッシュパージAPI | Cloudflare/CloudFront APIでパージ | 即時反映。URLを変えずに使える | デプロイパイプラインの構築が必要 |
># Cloudflare Cache Purge(デプロイスクリプト例)
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything": true}'>【DevToolsでのキャッシュデバッグ手順】
1. Chrome DevToolsを開く(F12 or Ctrl+Shift+I)
2. Networkタブを選択
3. キャッシュの確認方法:
→ Status列で確認
→ 200(disk cache): ブラウザのディスクキャッシュから返された
→ 200(memory cache): ブラウザのメモリキャッシュから返された
→ 304: 条件付きリクエストで変更なし(ETag照合成功)
→ 200: 通常のリクエスト(キャッシュなし)
4. ETagの確認:
→ リクエストをクリック
→ Response Headers タブで ETag の値を確認
→ Request Headers タブで If-None-Match の値を確認
5. キャッシュを無効にしてテスト:
→ "Disable cache"チェックボックスをON(DevToolsが開いている間のみ)
→ または Ctrl+Shift+R でハードリロード
6. Lighthouseでキャッシュ効率を測定:
→ Audits タブ → "Uses efficient cache policy on static assets"
→ 長期キャッシュが設定されていないリソースの一覧が表示される>【キャッシュ設定前後のパフォーマンス比較例】
設定前(キャッシュなし):
初回アクセス:
GET /app.js → 200 (320KB, 850ms)
GET /styles.css → 200 (45KB, 320ms)
GET /hero.webp → 200 (180KB, 1200ms)
合計転送量:545KB、合計時間:2370ms
2回目アクセス:
GET /app.js → 200 (320KB, 800ms) ← 全部再取得
GET /styles.css → 200 (45KB, 310ms)
合計:変わらず重い
━━━━━━━━━━━━━━━━━━━━━
設定後(適切なキャッシュ):
初回アクセス:
GET /app.a1b2c3.js → 200 (320KB, 850ms)
GET /styles.d4e5f6.css → 200 (45KB, 320ms)
GET /hero.webp → 200 (180KB, 1200ms)
合計転送量:545KB(初回は変わらない)
2回目アクセス:
GET /app.a1b2c3.js → 200 (from disk cache, 0ms, 0bytes)
GET /styles.d4e5f6.css → 200 (from disk cache, 0ms, 0bytes)
GET /hero.webp → 200 (from disk cache, 0ms, 0bytes)
GET /index.html → 304 (Not Modified, 18ms, 200bytes)
合計転送量:200bytes(99.9%削減!)、合計時間:18msA. Viteはビルドで assets/main-abc123.js のようにファイル名にハッシュを付与します。このため、コンテンツが変わればURL自体が変わるので「同一URLは永久にキャッシュして良い(immutable)」という設定が可能です。サーバー側で Cache-Control: public, max-age=31536000, immutable を設定するのがベストプラクティスです。ただし、ビルドツールはファイルを生成するだけで、HTTPヘッダーの設定はサーバー(Nginx/Cloudflare/Vercel)側で行う必要があります。
A. 通常は強いETag("abc123")を推奨します。バイト単位で同一の場合のみ一致するため、精度が高く信頼性があります。弱いETag(W/"abc123")はセマンティックに同等であれば一致するため、HTTPとHTTPS間やgzip圧縮の有無など「内容は同じだが表現が異なる」ケースに使います。NginxはデフォルトでファイルサイズとMtimeから弱いETagを生成します。コンテンツハッシュを使って独自生成する場合は強いETagにしてください。
A. これはよく混同されるポイントです。no-cacheは「キャッシュを保存するが毎回サーバーに確認する」という意味です(名前に反して、キャッシュを使わないわけではありません)。no-storeは「キャッシュを一切保存しない」という意味で、毎回サーバーからフルダウンロードします。HTMLやAPIレスポンスで「常に最新を確認したいが帯域は節約したい」場合は no-cache + ETag を使いましょう。個人情報や機密データは no-store を使います。
A. stale-while-revalidate の第二値で「古いデータを返す最大期間」を指定します。例えば max-age=60, stale-while-revalidate=300 なら、最大5分間は古いデータを返しますが、バックグラウンドで新しいデータを取得するので次のリクエストからは最新データが返ります。SEOや公式発表など「即時最新を表示すべき」コンテンツには向きませんが、一般的なリスト表示や参照データには最適です。
A. CDNがレスポンスをキャッシュしている場合、CDNがETagをブラウザに転送するかどうかは設定によります。Cloudflareは通常ETagをそのままブラウザに転送します。CDNキャッシュがある場合、ブラウザは If-None-Match をCDNに送り、CDNがオリジンサーバーに転送するかどうかはCDNの設定次第です。CDNは Vary: Accept-Encoding の設定が重要で、これがないとgzip圧縮済みのキャッシュを非対応ブラウザに返してしまうことがあります。
If-None-Match で変更確認し、変更なしは304を返して帯域を節約する。ブラウザキャッシュはパフォーマンスに直結する重要な設定です。「変えないものは長期キャッシュ(immutable)・変わりうるものはETagで確認(no-cache)・ユーザー固有データはキャッシュしない(no-store)」という3つの原則で設計すると、帯域幅の節約とコンテンツの鮮度を両立できます。


副業・フリーランスが主流になっている今こそ、自らのスキルで稼げる人材を目指してみませんか?
未経験でも心配することはありません。初級コースを受講される方の大多数はプログラミング未経験です。まずは無料カウンセリングで、悩みや不安をお聞かせください!
公式サイト より
今すぐ
無料カウンセリング
を予約!