この記事の要点
• ETagはリソースの「指紋」で、内容が変わったかを効率的に検証
• If-None-Matchヘッダーで条件付きリクエストを送り、変更がなければ304 Not Modifiedで転送量を削減
• CDNやブラウザキャッシュと組み合わせて、パフォーマンスを最大化
ETagとは
ETag(Entity Tag)は、HTTPレスポンスヘッダーで送信される、リソースの特定バージョンを識別する文字列です。ファイルの内容から生成されるハッシュ値や、更新タイムスタンプを使って、「このリソースが変更されたかどうか」を効率的に判定できます。
なぜ必要か: ファイル全体を毎回送信すると帯域を無駄に消費します。ETags を使えば、「内容が変わっていない」ことを確認し、304レスポンスでボディを省略できます。
基本的なフロー
1. 初回リクエスト
クライアントがリソースを初めて取得します。
GET /style.css HTTP/1.1
Host: example.com
レスポンス:
HTTP/1.1 200 OK
Content-Type: text/css
Content-Length: 1234
ETag: "abc123xyz"
Cache-Control: max-age=3600
/* CSS content */
ブラウザはETag: "abc123xyz"を記録します。
2. 条件付きリクエスト(キャッシュ期限切れ後)
max-age=3600が経過すると、ブラウザは再検証します。
GET /style.css HTTP/1.1
Host: example.com
If-None-Match: "abc123xyz"
3a. 内容が変わっていない場合
HTTP/1.1 304 Not Modified
ETag: "abc123xyz"
Cache-Control: max-age=3600
ポイント: ボディがないため、転送量が大幅に削減されます。ブラウザはキャッシュから再利用します。
3b. 内容が変わった場合
HTTP/1.1 200 OK
Content-Type: text/css
Content-Length: 1456
ETag: "def456uvw"
Cache-Control: max-age=3600
/* Updated CSS content */
ポイント: 304レスポンスはボディを含まないため、1MBの画像でもヘッダーだけ(数百バイト)で応答できます。
ETagの生成方法
ファイルハッシュ
ファイル内容のMD5やSHA-256ハッシュを使用します。
const crypto = require('crypto');
const fs = require('fs');
function generateETag(filePath) {
const content = fs.readFileSync(filePath);
const hash = crypto.createHash('md5').update(content).digest('hex');
return `"${hash}"`;
}
// ETag: "5d41402abc4b2a76b9719d911017c592"
利点:
- 内容が1バイトでも変われば異なるETags
- 複数サーバーで同じファイルなら同じETags
欠点:
- ファイルサイズが大きいとハッシュ計算に時間がかかる
ファイルメタデータ
更新時刻とサイズの組み合わせを使用します。
function generateETag(filePath) {
const stats = fs.statSync(filePath);
const mtime = stats.mtime.getTime();
const size = stats.size;
return `"${mtime}-${size}"`;
}
// ETag: "1714521600000-1234"
利点:
- 高速(ハッシュ計算不要)
欠点:
- タイムスタンプが異なるサーバーで一致しない可能性
Nginxのデフォルト実装
Nginxは「最終更新時刻のタイムスタンプ(16進数)-ファイルサイズ(16進数)」を使用します。
ETag: "6626f2a0-4d2"
6626f2a0: 最終更新時刻(Unixタイムスタンプの16進数)4d2: ファイルサイズ(1234バイトの16進数)
実践メモ: 複数サーバーでロードバランシングする場合、同じ内容には同じETagを返すことが重要です。Nginxのデフォルトは時刻ベースなので注意が必要です。
強いETag と 弱いETag
強いETag(Strong ETag)
ETag: "abc123xyz"
意味: バイト単位で完全一致。1バイトでも違えば異なるETag。
用途: 画像、動画、実行ファイルなど、正確性が重要なリソース。
弱いETag(Weak ETag)
ETag: W/"abc123xyz"
意味: 意味的に同等。多少の違いは許容。
用途:
- 動的HTML(広告部分が変わる程度)
- gzip圧縮(圧縮レベルの違いを無視)
- テキストファイル(改行コードの違いを無視)
例(Nginx設定):
location /api/ {
# gzip圧縮時に弱いETagを使用
gzip on;
etag on;
}
圧縮前と圧縮後で内容が異なるため、弱いETagを使います。
注意: If-Matchヘッダー(PUT/PATCHでの楽観的ロック)では、弱いETagは使用できません。強いETagのみ有効です。
条件付きリクエストヘッダー一覧
| ヘッダー | 用途 | レスポンス |
|---|---|---|
| If-None-Match | GETでキャッシュ検証 | 304 Not Modified |
| If-Match | PUT/PATCH/DELETEで楽観的ロック | 412 Precondition Failed |
| If-Modified-Since | GETで時刻ベース検証 | 304 Not Modified |
| If-Unmodified-Since | PUT/PATCH/DELETEで時刻ベースロック | 412 Precondition Failed |
| If-Range | 部分ダウンロード継続の検証 | 206 Partial Content / 200 OK |
If-None-Match(キャッシュ検証)
GET /image.jpg HTTP/1.1
If-None-Match: "abc123xyz"
サーバーの判定:
- ETagが一致 → 304 Not Modified
- ETagが不一致 → 200 OK(新しい内容を返す)
If-Match(楽観的ロック)
PUT /api/posts/123 HTTP/1.1
If-Match: "abc123xyz"
Content-Type: application/json
{"title": "Updated Title"}
サーバーの判定:
- ETagが一致 → 更新実行、200 OK
- ETagが不一致 → 412 Precondition Failed(誰かが先に更新済み)
ユースケース: 複数ユーザーが同じリソースを編集する際、古いデータで上書きするのを防ぐ。
// フロントエンド
const response = await fetch('/api/posts/123');
const data = await response.json();
const etag = response.headers.get('ETag');
// ユーザーが編集
// 保存時にETagを送信
await fetch('/api/posts/123', {
method: 'PUT',
headers: {
'If-Match': etag,
'Content-Type': 'application/json'
},
body: JSON.stringify({ title: 'Updated Title' })
});
Last-Modified と If-Modified-Since
ETagの代わりに、タイムスタンプベースの検証も可能です。
初回レスポンス
HTTP/1.1 200 OK
Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT
Content-Length: 1234
条件付きリクエスト
GET /style.css HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT
レスポンス
HTTP/1.1 304 Not Modified
Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT
ETag vs Last-Modified
| 項目 | ETag | Last-Modified |
|---|---|---|
| 精度 | 内容ベース(正確) | 時刻ベース(1秒単位) |
| 複数サーバー対応 | ハッシュなら一致 | 時刻ずれで不一致 |
| 計算コスト | 高(ハッシュ計算) | 低(ファイルメタデータ) |
| 優先度 | 高(両方あればETag優先) | 低 |
ポイント: ETagとLast-Modifiedの両方がある場合、ブラウザはETagを優先します。両方使うことで、古いクライアントにも対応できます。
実装例
Express.js(Node.js)
ExpressはデフォルトでETags を自動生成します。
const express = require('express');
const app = express();
// デフォルトでETags有効
app.get('/api/posts', (req, res) => {
const posts = getPosts();
res.json(posts);
// ETagが自動で付与される
});
// カスタムETag
app.get('/image.jpg', (req, res) => {
const filePath = './image.jpg';
const etag = generateETag(filePath);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set('ETag', etag);
res.sendFile(filePath);
});
// ETags無効化
app.set('etag', false);
Nginx
http {
# ETag有効(デフォルト)
etag on;
# 静的ファイル配信
location /static/ {
root /var/www;
etag on;
# Cache-Controlと併用
add_header Cache-Control "public, max-age=3600";
}
# API レスポンス
location /api/ {
proxy_pass http://backend;
# バックエンドのETagを保持
proxy_set_header If-None-Match $http_if_none_match;
}
}
Apache
# ETag有効(デフォルト)
FileETag MTime Size
# またはハッシュベース
FileETag Digest
# キャッシュコントロールと併用
Header set Cache-Control "public, max-age=3600"
Cloudflare Workers
export default {
async fetch(request) {
const url = new URL(request.url);
const cache = caches.default;
// キャッシュチェック
let response = await cache.match(request);
if (!response) {
// オリジンから取得
response = await fetch(request);
// ETag付きでキャッシュ
const headers = new Headers(response.headers);
const etag = generateETag(await response.clone().arrayBuffer());
headers.set('ETag', etag);
const newResponse = new Response(response.body, { headers });
await cache.put(request, newResponse.clone());
return newResponse;
}
// If-None-Match チェック
const ifNoneMatch = request.headers.get('If-None-Match');
const etag = response.headers.get('ETag');
if (ifNoneMatch === etag) {
return new Response(null, {
status: 304,
headers: { 'ETag': etag }
});
}
return response;
}
};
CDNとの連携
Vary: Accept-Encoding との組み合わせ
圧縮形式ごとに異なるETagが必要です。
HTTP/1.1 200 OK
Content-Encoding: br
ETag: "abc123-br"
Vary: Accept-Encoding
理由: gzip圧縮版とBrotli圧縮版で内容が異なるため、異なるETags が必要。
CDNのETag書き換え
一部のCDNは独自のETagを付与します。
# オリジン
ETag: "abc123"
# CDN経由
ETag: "abc123-cloudflare"
問題: If-None-Matchがオリジンと一致しなくなる。
解決策: CDN設定でオリジンのETagを尊重するよう設定。
// Cloudflare Workers で修正
const response = await fetch(request);
const originalEtag = response.headers.get('ETag');
// オリジンのETagを保持
return new Response(response.body, {
headers: {
...response.headers,
'ETag': originalEtag
}
});
Range リクエストとの組み合わせ
If-Range ヘッダー
動画や大きなファイルの部分ダウンロードを継続する際に使用します。
GET /video.mp4 HTTP/1.1
Range: bytes=1000000-
If-Range: "abc123xyz"
サーバーの判定:
- ETagが一致 → 206 Partial Content(続きを返す)
- ETagが不一致 → 200 OK(全体を返す)
レスポンス(一致時):
HTTP/1.1 206 Partial Content
Content-Range: bytes 1000000-1999999/2000000
ETag: "abc123xyz"
Content-Length: 1000000
レスポンス(不一致時):
HTTP/1.1 200 OK
ETag: "def456uvw"
Content-Length: 2000000
実践メモ: If-Rangeを使わないと、ファイルが更新されたときに壊れたファイルになる可能性があります。動画配信では必須です。
パフォーマンス最適化
Cache-Control との併用
Cache-Control: max-age=3600
ETag: "abc123xyz"
フロー:
- 0〜3600秒: キャッシュから取得(サーバーアクセスなし)
- 3600秒後: If-None-Matchで検証 → 304レスポンス
- 内容変更時: 200レスポンス(新しいETag)
ハッシュ付きファイル名との使い分け
# ハッシュ付きファイル名
/assets/app.a1b2c3.js
Cache-Control: max-age=31536000, immutable
# ETag不要(ファイル名が変われば別ファイル)
# ハッシュなしファイル名
/style.css
Cache-Control: max-age=3600
ETag: "abc123xyz"
# ETagで効率的に検証
デバッグとトラブルシューティング
Chrome DevTools で確認
-
Network タブ → Response Headers
ETagの値を確認- 304レスポンスか200レスポンスかを確認
-
Size 列
(from cache): キャッシュから取得304: 304レスポンス(数百バイト)1.2 MB: 200レスポンス(全体)
cURLでテスト
# 初回リクエスト
curl -I https://example.com/style.css
# ETagを使って検証
curl -I -H "If-None-Match: \"abc123xyz\"" https://example.com/style.css
# Last-Modifiedを使って検証
curl -I -H "If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT" https://example.com/style.css
よくある問題
304が返らない:
- ETagがリクエストごとに変わっていないか確認
- 複数サーバーで異なるETagを返していないか確認
- If-None-Matchヘッダーが正しく送信されているか確認
キャッシュが効きすぎる:
- Cache-ControlのMax-Ageを短くする
- HTMLにはno-cacheを使う
ベストプラクティス
1. 適切なリソースにETags を使用
| リソース | ETag | Last-Modified | Cache-Control |
|---|---|---|---|
| 静的ファイル(hash付き) | 不要 | 不要 | max-age=31536000, immutable |
| 静的ファイル(hashなし) | ✓ | ✓ | max-age=3600 |
| HTML | ✓ | ✓ | no-cache |
| API | ✓ | 不要 | private, max-age=60 |
| 動画・大ファイル | ✓ | ✓ | max-age=86400 |
2. 弱いETagを適切に使用
# 動的HTML(広告が変わる程度)
ETag: W/"abc123"
# 静的ファイル(完全一致が必要)
ETag: "abc123"
3. 楽観的ロックでデータ競合を防ぐ
// GETでETagを取得
const res = await fetch('/api/posts/123');
const post = await res.json();
const etag = res.headers.get('ETag');
// PUTでIf-Matchを送信
await fetch('/api/posts/123', {
method: 'PUT',
headers: {
'If-Match': etag,
'Content-Type': 'application/json'
},
body: JSON.stringify({ title: 'Updated' })
});
4. 複数サーバーでETag一貫性を保つ
// ファイルハッシュベースのETag
const hash = crypto.createHash('md5').update(content).digest('hex');
const etag = `"${hash}"`;
// サーバーAでもサーバーBでも同じETag
注意: Nginxのデフォルトは時刻ベースのETagなので、ロードバランサー環境ではファイルハッシュベースに変更することを推奨します。
まとめ
ETagと条件付きリクエストは、HTTPキャッシュ戦略の中核です。If-None-Matchによる効率的な検証で304レスポンスを活用し、転送量を大幅に削減できます。CDN連携、楽観的ロック、Range リクエストとの組み合わせで、高速かつ安全なWebアプリケーションを実現しましょう。
参考リソース
- MDN - ETag
- MDN - If-None-Match
- MDN - HTTP conditional requests
- RFC 9110 - HTTP Semantics (ETags)
- Google - HTTP Caching (ETags)