HTTP / Cloudflare Workers

HTTP の条件付きリクエスト — ETag と Last-Modified

ブラウザに何をどれだけ覚えさせるかは、秒数を決める問題ではなく「いつ変わるか」を言葉にする問題である。個人用のランニング記録アプリを題材に、条件付きリクエストを組み込むまでの判断と、その過程で踏んだ 3 つの落とし穴を辿る。

01三択のどれを選ぶか

ブラウザに何かをキャッシュさせるとき、取れる方針は実質 3 つしかない。「させない」「秒数を決めて任せる」「毎回確かめさせる」である。速いのは 2 番目で、正しいのは 1 番目と 3 番目である。

この文書は 3 番目、つまり 条件付きリクエスト の設計を扱う。題材は個人用のランニング記録アプリ marathon-log(Cloudflare Workers + D1)で、そこで実際に踏んだ 3 つの落とし穴 — Cloudflare が HTML の ETag を落とすこと、Last-Modified を何から作るか、Workers のグローバルスコープで Date.now() が 0 を返すこと — まで含めて辿る。

方針 毎回サーバーに聞くか 古い内容を出しうるか 転送量 no-store そもそも保存させない 聞く 出さない 毎回 全文 max-age = N 秒 N 秒のあいだは聞かない 聞かない 出しうる 0 no-cache + 検証子 保存はする。使う前に確かめる 聞く 出さない 変化時だけ 全文
FIG 013 つの方針。max-age だけが「古い内容を出しうる」欄で落ちる

02Cache-Control の指示を読み分ける

名前が紛らわしいので、まず RFC 9111 の定義を確認しておく。とくに no-cache は「キャッシュするな」ではない。

no-store(5.2.2.5)
「キャッシュはリクエスト・レスポンスのいかなる部分も保存してはならない」。保存そのものを禁じる。
no-cache(5.2.2.4)
「検証のために転送し、成功の応答を受け取るまでは、そのレスポンスを他のリクエストに使ってはならない」。保存はしてよい。使う前に確認せよという指示である。
private(5.2.2.7)
「共有キャッシュはこのレスポンスを保存してはならない」。1 人のためのレスポンスだという宣言で、ブラウザのキャッシュには残る。
max-age(5.2.2.1)
指定した秒数のあいだは新鮮とみなしてよい。この間、ブラウザはサーバーに問い合わせない。

ここで注意がいるのが「何も付けない」場合である。何も付けなければキャッシュされない、ということにはならない。RFC 9111 4.2.2 は、明示的な有効期限がないときキャッシュはヒューリスティックに期限を決めてよいと定めている。禁じているのはその逆 — 「保存されたレスポンスに明示的な有効期限があるとき、キャッシュはヒューリスティックを使ってはならない」 — だけである。

ヘッダを付けないのは「キャッシュしない」ではなく「ブラウザに任せる」である。判断が実装ごとに変わるうえ、開発中にデプロイしたのに古い画面が出続ける、という形で表面化する。意図がどちらであれ、明示したほうがよい。

03条件付きリクエストの往復

no-cache を選ぶと、ブラウザは毎回サーバーに問い合わせる。このままでは no-store と変わらない。差が出るのは、サーバーが 検証子 を一緒に返しているときである。

検証子は 2 種類ある。ETag は内容を表す不透明な文字列で、ふつうは本文のハッシュを使う。Last-Modified は最終更新時刻である。ブラウザは次の要求でそれぞれ If-None-MatchIf-Modified-Since に載せて送り返す。サーバーは一致すれば 304 Not Modified を本文なしで返す。

ブラウザ サーバー 1 GET /races 200 + 本文 33 KB ETag: W/"a1b2…" / Last-Modified: … / Cache-Control: private, no-cache 2 GET /races + If-None-Match: W/"a1b2…" 304 本文なし(0 B) ブラウザは手元の写しをそのまま描く 3 GET /races + If-None-Match: W/"a1b2…"(走った後) 200 + 新しい本文
FIG 02問い合わせ自体は毎回起きる。省けるのは本文の転送である

両方を送ってきたときの扱いは決まっている。RFC 9110 13.2.2 は If-None-Match を先に評価し、それがあるときは If-Modified-Since を見ないと定めている。片方が一致し片方が外れたときに 304 を返してしまわないよう、実装でもこの順を守る。

本文のハッシュを ETag にする方式では、304 を返すためにも本文を組み立てなければならない。省けるのは転送と再描画だけで、サーバー側は軽くならない。ただし Last-Modified のほうは本文を見ずに決まる。この差が後で効いてくる(9 章)。

04max-age を選ばなかった理由

marathon-log の画面は、そう頻繁には変わらない。内容が変わる条件は 4 つしかない。

変わる条件頻度起点
ワークアウトが取り込まれた走った日に数回iPhone のショートカットからの POST
計画をいじった(目標変更・再生成・組み替え)まれ画面からの POST
日付が変わった1 日 1 回時計
デプロイした開発中だけwrangler

これだけ見れば max-age がよく効きそうに思える。実際 1 日の大半は同じ内容である。それでも選ばなかったのは、このアプリのいちばん多い使われ方が「走り終えてショートカットを実行し、その直後に開く」だからである。ここで古い画面が出ると、アプリの用をなさない。日付をまたいだ直後も同じで、今日・未実施・未来の区別と残り日数が一斉に変わる。

つまり 更新の頻度が低いことと、古い内容を見せてよいことは別の話である。更新が低頻度でも、更新の直後に見られるものは古くしてはいけない。秒数で区切る方式は、まさにその瞬間を外す。

結論として Cache-Control: private, no-cache を選んだ。private を付けているのは、認証の内側にある個人のデータなので共有キャッシュに残したくないためである。

05workers.dev では HTML の ETag が落ちる

ETag を返すコードを書いてデプロイし、ヘッダを確認したところ、JSON には付いているのに HTML には付いていなかった。

$ curl -sI https://<name>.workers.dev/api/v1/health
content-type: application/json; charset=utf-8
cache-control: private, no-cache
etag: W/"41aa6b2e763e…"          ← 残っている

$ curl -sI https://<name>.workers.dev/races
content-type: text/html; charset=utf-8
cache-control: private, no-cache
                                  ← etag がない

Worker のコードは両者で同じ経路を通る。ローカルの wrangler dev で同じ URL を叩くと HTML にも ETag が付いていたので、落としているのは Cloudflare のエッジだと分かる。

理由は Cloudflare の仕様で説明が付く。ETag のドキュメントは、HTML を書き換える機能が有効だと ETag が取り除かれると述べており、弱い ETag を使う場合は Email Obfuscation と Automatic HTTPS Rewrites を無効にする必要があると明記している。Rocket Loader や自動 Minify も同じ扱いである。workers.dev のサブドメインは Cloudflare が管理するゾーンなので、これらの設定を利用者が触ることはできない。

Worker ETag と Last-Modified の両方を付ける Cloudflare のエッジ Email Obfuscation Automatic HTTPS Rewrites Rocket Loader / Minify HTML を書き換えうる application/json ETag ○ Last-Modified ○ If-None-Match で判定できる text/html ETag ✕ Last-Modified ○ If-Modified-Since に頼るしかない
FIG 03自前ドメインなら該当機能を切って ETag を残せる。workers.dev では選べない

幸い Last-Modified は HTML でも残る。そこで検証子を 2 つ出し、JSON は ETag で、HTML は Last-Modified で判定することにした。自前ドメインに移して該当機能を切れば、HTML でも ETag が効くようになる。

06Last-Modified を何から作るか

ETag は本文のハッシュでよいので迷う余地がない。難しいのは Last-Modified である。日時を出さなければならないので、内容のハッシュから作ることができない。「内容が変わったときに必ず新しくなる、現在時刻以下の日時」を別途用意する必要がある。

4 章で挙げた 4 つの変化のうち、日付とデプロイは時刻そのものから作れる。残るデータの更新をどう捕まえるかだが、ここは 1 か所で済む。データを変える操作はすべて GET 以外のリクエストである。ルーターの出口で「GET 以外が成功したら現在時刻を記録する」と書けば、取り込みも目標変更も組み替えも一網打尽になる。

export async function route(req, env, app) {
  const res = await handle(req, env, app);
  if (req.method !== "GET" && res.status < 400) {
    // 書き込みが通った。次からの Last-Modified がこれで新しくなる
    await app.settings.set("data_rev", new Date().toISOString());
    return res;
  }
  return withValidators(req, res, () => app.settings.get("data_rev"), env.BUILD_STAMP);
}

あとは 3 つの時刻の最大を採る。

data_rev GET 以外が成功した時刻。取り込み・目標変更・組み替え 今日の 0 時(JST) 今日・未実施・未来の区別と残り日数が変わる BUILD_STAMP デプロイ時刻。--var で渡す max 秒に丸める Last-Modified Fri, 18 Sep 2026 12:06:31 GMT colo が違っても同じ値になる
FIG 043 つの時刻の最大。どれか 1 つでも進めば検証子が変わる

秒に丸めるのを忘れないこと。Last-Modified の書式は秒までしか表せないので、ミリ秒を持ったまま比較すると、返した値をそのまま送り返されても「それより後に更新された」と判定してしまい、永久に 304 が返らない。

デプロイ時刻は wrangler deploy --var BUILD_STAMP:$(date -u +%Y-%m-%dT%H:%M:%SZ) で渡している。isolate の起動時刻でも代用できそうに見えるが、これは colo ごとにばらつくため、リクエストのたびに Last-Modified が揺れて 304 が安定しない。実測でも 3 回連続で 200 が返った。

07Workers の時計はグローバルスコープで止まっている

その isolate の起動時刻を使おうとして、最初はこう書いた。

const ISOLATE_START = Date.now();   // モジュールのトップレベル

ところがデプロイしても Last-Modified が更新されず、値は常に「今日の 0 時」になった。ISOLATE_START が最大値に一度も勝っていない。つまり 0 が入っている。

Workers のドキュメントに答えがある。「Date.now() は最後の I/O の時刻を返す。コードの実行中には進まない」。同じ理由で performance.now() も精度が落とされている。タイミング攻撃を防ぐための設計である。

グローバルスコープが評価されるのは isolate の起動時で、その時点ではまだ I/O が 1 度も起きていない。したがって「最後の I/O の時刻」は存在せず、0 — Unix エポック — が返る。

グローバルスコープ import と定数の評価 Date.now() === 0 I/O がまだ 1 度も起きていない 最初のリクエスト fetch ハンドラの中 実時刻が返る 直前の I/O の時刻 同じハンドラの続き I/O を挟まない計算 値は進まない 経過時間の計測はできない
FIG 05グローバルスコープで時刻を取ると 0 になる。起動時刻のつもりで使えない

このふるまいは黙って 0 を返すだけで、エラーにも警告にもならない。「デプロイ時刻」「起動時刻」「バージョン」をグローバルスコープの時計から作ろうとすると、テストでは気付かないまま本番で機能しない。こういう値はビルド時に外から渡すのが確実である。

08実装と、結局どこが速くなるのか

できあがった検証子の付与はこうなる。ルーターの出口に 1 か所だけ置く。

const CACHEABLE = /^(text\/html|application\/json)/;

export async function withValidators(req, res, dataRev, buildStamp) {
  if (req.method !== "GET" || res.status !== 200) return res;
  // アイコンのように自前の cache-control を持つものは触らない
  if (!CACHEABLE.test(res.headers.get("content-type") ?? "") || res.headers.has("cache-control")) return res;

  const body = await res.clone().arrayBuffer();
  const digest = await crypto.subtle.digest("SHA-1", body);
  const etag = `W/"${[...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("")}"`;

  const rev = Date.parse((await dataRev()) ?? "");
  const built = Date.parse(buildStamp ?? "");
  // ミリ秒は Last-Modified で表せないので秒に丸める
  const lastMs = Math.floor(Math.max(built || 0, dayStartMs(Date.now()), rev || 0) / 1000) * 1000;

  const headers = new Headers(res.headers);
  headers.set("etag", etag);
  headers.set("last-modified", new Date(lastMs).toUTCString());
  headers.set("cache-control", "private, no-cache");

  // RFC 9110 13.2.2: If-None-Match があるときは If-Modified-Since を見ない
  const inm = req.headers.get("if-none-match");
  const ims = Date.parse(req.headers.get("if-modified-since") ?? "");
  const fresh = inm !== null ? inm === etag : isFinite(ims) && ims >= lastMs;
  return fresh ? new Response(null, { status: 304, headers }) : new Response(body, { status: 200, headers });
}

実測は次のとおりである。ダッシュボードの JSON は 128 KB あるので、変化していないときの往復がそのまま 0 になる。

要求応答本文
HTML、条件なし20033 KB
HTML、If-Modified-Since 付き3040
HTML、計画を組み替えた直後に同じ条件20033 KB
JSON、条件なし200128 KB
JSON、If-None-Match 付き3040

ここまでで速くなったのは本文の転送と、ブラウザ側の再解析・再描画である。サーバー側は軽くなっていない。304 を返す場合でも D1 の読み取りと HTML の組み立ては行われ、data_rev を読むぶんクエリはむしろ 1 本増えている。次章でここを詰める。

09本文を作る前に打ち切る

転送量ではなくデータベースの使用量を減らしたい、という動機なら話が変わる。D1 の課金は 読んだ行数 で数える。ドキュメントは「行の読み取りは、それぞれの行の大きさに関係なく、クエリが何行を読んだ(走査した)かを測る」「1 KB の行も 100 KB の行も 1 行として数える」と述べている。列を絞っても、返す行を減らさなければ数は変わらない。

marathon-log のダッシュボードを 1 回開くと、こうなる。

クエリ読んだ行
plan_days(レースの計画 1 件ぶん)70
workouts(直近 200 件と期間指定の 2 本)11
races / settings ×23
合計84

ここで 3 章の差が効く。ETag は本文のハッシュなので本文がないと決まらないが、Last-Modified は本文を見ずに決まる。6 章で作った 3 つの時刻はどれもページの中身と無関係に取れる。つまり If-Modified-Since だけを持つ条件付きリクエストは、ハンドラを呼ぶ前に判定して打ち切れる。

export async function route(req, env, app) {
  if (req.method !== "GET") { /* 書き込み。data_rev を進める */ }

  // 本文を作る前に打ち切れるものはここで返す。D1 の読み取りが 1 行で済む
  if (canEarlyOut(req) && authOk(req, env)) {
    const lastMs = await lastModifiedMs(dataRev, env.BUILD_STAMP);
    if (Date.parse(req.headers.get("if-modified-since") ?? "") >= lastMs) return notModified(lastMs);
  }
  return withValidators(req, await handle(req, env, app), dataRev, env.BUILD_STAMP);
}

読む行は data_rev の 1 行だけになる。84 分の 1 である。応答も 0.19 秒から 0.06〜0.10 秒に落ちた。

打ち切る前に認証を通すこと。セッションが切れているのに 304 を返すと、ログイン画面へ送るべき場面で古いページがそのまま表示される。幸いこのアプリの認証はトークンのハッシュ比較だけで、データベースを引かない。

対象も絞る。If-None-Match が付いているときは本文まで作る(RFC 9110 13.2.2 でそちらが優先でもある)。自前の Cache-Control で配っているアイコンやマニフェストも除く。

もっとも、この規模で D1 の無料枠を心配する必要はない。1 日 500 万行に対して 1 画面 84 行なので、1 日 6 万回開ける。それでも入れる価値があるのは、行数が効いてくるのは利用者や計画が増えたときであり、そのときには「打ち切れる形になっているかどうか」を後から変えるほうが高くつくからである。

ヘッダを付けないという最初の状態と比べると、変わったのは「ブラウザに委ねるのをやめて、こちらで決めた」という 1 点である。キャッシュの設計とは結局それに尽きる。