「サイトを開くと、たまに『読み込みエラーが発生しました』と出ます。リロードすると直るのですが」——個人開発で運用している技術ブログ群で、こうした報告がぽつぽつ届くようになりました。やっかいだったのは、自分の手元では一度も再現しなかったことです。何十回リロードしても正常に表示されます。アクセスログを見ても 5xx はほとんど立っていません。
結論から申し上げると、原因は「Next.js が SSR の途中で起こした例外を HTTP 200 のまま配信し、それを Cloudflare のエッジキャッシュが数時間固定化していた」ことでした。200 で返るので監視にも引っかからず、キャッシュされるので一部の利用者だけが壊れたページを踏み続けるという、観測しづらい組み合わせです。切り分けの過程と、再発させないために cache-worker へ入れたガードのコードを、以下に残しておきます。
200 で返るエラーページという盲点
まず前提として、Next.js の App Router は本文をストリーミングで送り出します。ヘッダ(ステータスコード)はボディより先にフラッシュされるため、200 OK を送った後に React のレンダリング中で例外が起きても、もうステータスコードは書き換えられません。代わりに error.tsx のエラー UI がボディの続きとして流れていきます。
つまり利用者から見れば「読み込みエラーが発生しました」という画面なのに、HTTP のステータスは 200 OK。これが第一の盲点でした。5xx を数えるダッシュボードでは異常がまったく見えません。
もう一つの発生経路がありました。記事本文は getCloudflareContext().env.ASSETS.fetch() 経由で静的 HTML を読み込む構成にしているのですが、デプロイが切り替わる一瞬、この ASSETS への取得が空振りすることがあります。すると例外こそ出ないものの、本文が空のまま記事ページが 200 で生成されてしまいます。読み込みエラー画面と空本文ページ、症状は違いますが「壊れた 200」という点で同じ穴に落ちていました。
なぜエッジキャッシュが事態を悪化させたのか
ここに Cloudflare Workers の cache-worker を重ねると、問題が増幅します。当時の cache-worker は素朴で、200 の HTML をほぼ無条件に 4 時間エッジへキャッシュしていました。
// 当時の素朴な実装(問題があった版)
const res = await fetch (request);
if (res.status === 200 && isHTML (res)) {
const toCache = res. clone ();
ctx. waitUntil (cache. put (request, toCache)); // ← 200 なら中身を見ずに保存
}
return res;
デプロイの切り替わりは 1 日に十数回起こります。その一瞬に生成された「壊れた 200」が運悪くキャッシュへ入ると、以降 4 時間、同じエッジロケーションを使う利用者にだけ壊れたページが配信され続けます。私の手元で再現しなかったのは、別ロケーションの健全なキャッシュを引いていたからでした。「リロードすると直る」という報告も、キャッシュのばらつきとデプロイ間隔を考えると筋が通ります。
公式ドキュメントには「200 はキャッシュ可能」と書かれていますが、ストリーミング SSR では「ステータス 200」と「中身が正常」は別物です。ここを同一視していたのが設計上の誤りでした。
Antigravity のエージェントで切り分けた手順
手元で再現しない以上、ログと挙動から仮説を立てて潰していくしかありません。私はこの切り分けに Antigravity のエージェントを使いました。コードベース全体を読ませた上で、調査を一つのタスクとして任せられるのが、こういう「再現しないバグ」では特に効いてきます。
実際に投げた指示はおおよそ次のような内容でした。
症状: 利用者から「時々読み込みエラーが出る/本文が空」と報告される。手元で再現しない。
やってほしいこと:
1. error.tsx / global-error.tsx がどのステータスコードで配信されるか確認する
2. ASSETS バインディング経由の本文取得が失敗したとき、ページがどう描画されるか追う
3. cache-worker が「壊れた 200」を保存しうる経路を洗い出す
仮説と再現条件を、コードの該当箇所を引用しながら根拠つきで提示すること。
エージェントは error.tsx がエラーバウンダリとしてボディに描画される一方、レスポンスは 200 のまま流れることをコードから示し、cache-worker の保存条件が status === 200 だけに依存している点を指摘してきました。人間が「まさかエラーページがキャッシュされるはずがない」と無意識に除外しがちな経路を、先入観なしに候補へ挙げてくれたのが効きました。
ここでの私の判断は、エージェントの仮説をそのまま信じ込まないことです。指摘された該当行を自分で開き、error.tsx に一時的なマーカー文字列を仕込んでステージングへデプロイし、curl -sI でステータスが 200 であることと、ボディにマーカーが含まれることを目視で確認してから対策に進みました。私自身、再現しないバグほど、仮説の検証ステップを省かないことが大切だと考えています。
対策1: エラーページに検出可能なマーカーを付ける
キャッシュ層が「このページは壊れている」と判断できるようにするには、まずエラー UI 自身に機械可読な目印が要ります。error.tsx と global-error.tsx のルート要素へ、データ属性を一つ足しました。
// app/[locale]/error.tsx
"use client" ;
export default function Error ({
error ,
reset ,
} : {
error : Error & { digest ?: string };
reset : () => void ;
}) {
return (
// data-error-boundary がキャッシュ層からの目印になります
< div data-error-boundary = "1" className = "error-shell" >
< h1 >読み込みエラーが発生しました</ h1 >
< button onClick = { () => reset () } >再読み込み</ button >
</ div >
);
}
global-error.tsx にも同じ属性を付けます。これで「ボディに data-error-boundary が含まれる HTML はキャッシュしない」という単純な判定が可能になります。
対策2: cache-worker に「壊れた 200」を弾くガードを入れる
本丸はここです。cache-worker の保存条件を、ステータスだけでなく中身も見るように書き換えました。判定はあえて素朴に保ち、誤ってキャッシュしない方向に倒しています。
// 壊れた 200 をキャッシュしないためのガード
function isCacheableHtml ( bodyText ) {
// (1) エラーバウンダリのマーカーを含むページは保存しない
if (bodyText. includes ( 'data-error-boundary' )) return false ;
// (2) HTML が途中で切れている(ストリーミング中断)場合は保存しない
if ( ! bodyText. includes ( '</html>' )) return false ;
// (3) 記事本文コンテナが空=ASSETS 取得失敗の疑い
const m = bodyText. match ( /<div [ ^ >] * class=" [ ^ "] * article-content [ ^ "] * " [ ^ >] * >( [\s\S] *? )< \/ div>/ );
if (m && m[ 1 ]. trim (). length < 20 ) return false ;
return true ;
}
async function handleResponse ( request , res , ctx , cache ) {
if (res.status !== 200 || ! isHTML (res)) {
return res; // HTML 以外と非 200 はそもそも保存しない
}
// 保存判定のためにボディを一度読む。元レスポンスは clone から再構築する
const cloned = res. clone ();
const bodyText = await cloned. text ();
if ( isCacheableHtml (bodyText)) {
ctx. waitUntil (cache. put (request, new Response (bodyText, res)));
}
// 壊れていてもこのリクエスト自体には素のレスポンスを返す(次回で回復)
return res;
}
ポイントは三つあります。第一に、判定材料はマーカー・</html> の有無・本文コンテナの中身という、外形的で安定したシグナルだけに絞ったことです。複雑な条件にすると、今度はそのガード自体がバグの温床になります。第二に、res.clone() でボディを読み、元の res はそのまま利用者へ返している点です。ボディは一度しか読めないので、ここを誤ると正常ページまで壊します。第三に、「壊れたページを今回のリクエストでは返してしまうが、キャッシュには残さない」という割り切りです。固定化さえ防げれば、次のリクエストで健全な版を引き直せます。
対策3: ASSETS の一時失敗に 1 回だけリトライを入れる
空本文ページの根本側にも手を入れました。デプロイ遷移時の ASSETS 取得の空振りは一瞬なので、ごく短い間隔で 1 回だけ再試行すると、ほとんどのケースで本文が埋まります。
// content.ts — ASSETS 取得を 1 回だけリトライする
async function readStaticAsset ( path : string ) : Promise < string | null > {
const env = getCloudflareContext ().env;
for ( let attempt = 0 ; attempt < 2 ; attempt ++ ) {
const res = await env. ASSETS . fetch ( new Request ( `https://assets.local${ path }` ));
if (res.ok) {
const text = await res. text ();
if (text. trim (). length > 0 ) return text;
}
if (attempt === 0 ) await new Promise (( r ) => setTimeout (r, 50 )); // 50ms だけ待つ
}
return null ; // 2 回とも失敗したら null を返し、呼び出し側で 5xx にする
}
リトライ回数を 2 回以上に増やしたくなりますが、私はあえて 1 回に留めています。遷移時の空振りは本質的に一過性で、回数を増やしてもレイテンシが伸びるだけで成功率はほとんど変わらなかったためです。実測でも、1 回リトライで空本文の発生はほぼゼロまで落ちました。
対策4: 本当に失敗したときは 5xx + no-store で返す
リトライしても本文が取れなかった場合は、もう取り繕わずに正直なエラーを返します。ここで重要なのは、5xx のレスポンスに Cache-Control: no-store を付けることです。これを忘れると、今度は失敗レスポンスがキャッシュされかねません。
if (content === null ) {
return new Response ( "Temporarily unavailable" , {
status: 503 ,
headers: { "Cache-Control" : "no-store" },
});
}
加えて、cache-worker 側に DEPLOY_VERSION という定数を置き、デプロイのたびに値を変えています。これでキャッシュキーに版が混ざり、過去に万一固定化された壊れたページがあっても、デプロイ時点で一括失効します。ガードはあくまで「壊れたものを入れない」ための仕組みで、DEPLOY_VERSION は「過去のものを残さない」ための仕組み、と役割を分けて考えると整理しやすいです。
つまずきやすい点と、確認の順序
実装中に踏んだ落とし穴を共有します。一つ目は、res.clone() を忘れてボディを直接 text() で読み、利用者へ空のレスポンスを返してしまった件です。Cloudflare Workers のボディストリームは一度しか消費できないので、保存用と返却用で必ず分けます。二つ目は、本文コンテナの空判定をゆるくしすぎて、本来正常な短い記事まで「空」と誤判定した件です。閾値は実データを見ながら 20 文字程度に調整しました。
確認は次の順序で行うと早いです。最初に curl -sI でステータスを見て、エラー UI が 200 で返っていないかを切り分けます。次に curl -s | grep data-error-boundary でマーカーの有無を確認します。最後に、ステージングで DEPLOY_VERSION を変えてデプロイし、壊れたキャッシュが失効することを見ます。手元で再現しないバグは、こうして「キャッシュに入りうる経路」を一つずつ塞いだことが効く実感を確認できる確認手順を持っておくと、対処に迷いがなくなります。
Cloudflare Workers の設計や ASSETS バインディングの挙動をもう少し体系的に押さえたい場合は、Cloudflare Workers エッジ SaaS アーキテクチャの実践メモ や、ログから障害を追う観点でまとめたwrangler tail で Workers の不具合を 3 週間追った記録 も合わせて読んでいただけると、エッジ層の全体像がつかみやすいかと思います。
もし同じように「再現しないが利用者からは報告される」症状に向き合っているなら、まずは error.tsx が何のステータスコードで配信されているかを curl -sI で確認してみてください。そこが 200 だった瞬間に、原因の半分は見えてきます。お読みいただき、ありがとうございました。