夜中、自分のサイトの記事を開いたら「500 Internal Server Error」。
リロードすると、何事もなかったように表示されます。
数分後、別の記事でまた同じ画面。胸の奥がざわつきました。サーバーが落ちているなら、こんなに気まぐれな出方はしないはずです。
個人開発で、Dolice Labs として4つのNext.jsサイトをCloudflare Workers上で運用しています。デプロイは1日に何度も走ります。この「ときどき500、リロードで回復」という症状を、私自身しばらくキャッシュの問題だと思い込んでいました。結論から言うと、サーバーは一度も500を返していませんでした。
同じ環境で同じ症状に何度も出会ったからこそ、表示と実態のずれにようやく気づけました。再現性の低いエラーほど、思い込みで原因を決めつけてしまいがちです。
まず「本当に500なのか」を疑う
ブラウザの表示を信じる前に、HTTPレスポンスそのものを確認します。ここを飛ばすと、存在しないサーバーエラーを延々と追うことになります。
同じURLに対して、ステータスコードだけを取り出します。
# 表示は「500」だが、実際のステータスは?
curl -s -o /dev/null -w "%{http_code}\n" \
https://claudelab.net/articles/claude-code/your-article-slug
# 念のため10連続で叩いて揺れを見る
for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{http_code} " \
https://claudelab.net/articles/claude-code/your-article-slug
done; echo返ってきたのは、10回すべて 200 でした。
不正な Cookie を付けても、長大な slug を投げても、RSC ヘッダーを付けても、サーバーは一貫して200を返します。つまり「500」はサーバーの応答ではなく、ブラウザの中で起きた何かが描いた絵だったのです。
犯人は ChunkLoadError だった
ブラウザのコンソールを開いて、500画面が出た瞬間のログを残しておきます。そこに ChunkLoadError が並んでいました。
何が起きているか。Next.js は JavaScript をハッシュ付きの細かいチャンク(framework-abc123.js のようなファイル)に分割して配信します。新しいデプロイが走ると、これらのファイル名が変わります。
ところが、ユーザーのブラウザにはまだ古いページが残っています。そのページが「さっきまであったチャンク」を取りに行くと、サーバー側ではもう別名に置き換わっていて404。読み込みに失敗したJavaScriptは例外を投げ、Next.jsのクライアント側エラー境界(error.tsx)が発火します。
ここまでで、サーバーは無関係だとわかります。デプロイ頻度が高いサイトほど、この「古いタブが新しいデプロイにぶつかる」瞬間が増えます。1日に何度もデプロイする私の運用では、毎日必ず誰かがこの瞬間を踏んでいた計算になります。
なぜ「500」と表示されたのか
src/app/[locale]/error.tsx を開いて、ようやく腑に落ちました。
// 旧実装(問題あり): あらゆるクライアントエラーを「500」と決めつけていた
'use client';
export default function Error({ error, reset }: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="error-page">
<h1>500 Internal Server Error</h1>
<p>サーバーでエラーが発生しました</p>
<button onClick={reset}>再試行</button>
</div>
);
}error.tsx は、ChunkLoadError でもハイドレーション不一致でも、クライアント側のあらゆる例外で表示されます。それなのに見出しに「500 Internal Server Error」と固定文言を書いていたため、サーバーとは無関係のエラーまで「サーバー500」に見えていたのです。
しかも reset() はコンポーネントの再マウントを試みるだけで、すでに失敗したチャンクの読み込みはやり直せません。だから「再試行」を押しても直らず、ユーザーが手でリロードしたときだけ回復していました。
自動リロードを組み込んで誤表示をなくす
修正の方針は3つです。ChunkLoadError の大半は新しいチャンクを取り直せば消えるので、初回エラー時に一度だけ自動リロードします。そして文言から「500」を外し、原因の手がかりをコンソールに残します。
'use client';
import { useEffect } from 'react';
export default function Error({ error, reset }: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// 原因究明のため、エラー名と digest を必ず残す
console.error('[error boundary]', error.name, error.message, error.digest);
// ChunkLoadError は古いチャンク参照が原因。一度だけ自動リロードで透過回復
const isChunkError =
error.name === 'ChunkLoadError' ||
/Loading chunk [\d]+ failed/.test(error.message);
if (isChunkError && typeof window !== 'undefined') {
const KEY = 'cl_chunk_reloaded';
if (!sessionStorage.getItem(KEY)) {
sessionStorage.setItem(KEY, '1');
window.location.reload();
}
}
}, [error]);
return (
<div className="error-page">
<h1>読み込みエラーが発生しました</h1>
<p>ページの読み込みに失敗しました。再読み込みで回復することがあります。</p>
<button onClick={() => {
sessionStorage.removeItem('cl_chunk_reloaded');
window.location.reload();
}}>
ページを再読み込み
</button>
</div>
);
}ポイントは sessionStorage のフラグです。これがないと、リロード後にまた同じエラーが出たとき無限リロードに陥ります。一度自動リロードしたら印を付け、それでも直らなければ静かに手動ボタンへ委ねます。
sessionStorage はプライベートブラウジングで例外を投げることがあるので、本番では try/catch で包んでください。reset() は ChunkLoadError には効かないため、ここでは使っていません。
エッジキャッシュにエラー画面を焼き付けない
もう一つ見落としがちな点があります。Cloudflare のようなエッジでHTMLをキャッシュしている場合、デプロイ遷移の一瞬に生成された壊れた応答を、そのまま固定化してしまう危険です。
私の環境では、キャッシュ層でエラーの痕跡を含むHTMLを保存しないようガードを足しました。具体的には、エラー境界のマーカー(data-error-boundary のような目印)を含むHTML、</html> が欠落したHTML、本文が空の記事HTMLはキャッシュ対象から外します。
これでGooglebotに壊れたページが配信される経路も塞げます。クロール健全性の観点でも、エラー画面の固定化は避けたい挙動でした。Cloudflare運用全体の設計は、Next.jsをCloudflare Workersで運用するときの実務メモに整理しています。
同じ罠を踏まないための確認順序
症状が「ときどき500、リロードで回復」だったら、まず curl でステータスコードを確かめてください。200が返るなら、その時点でサーバーは容疑から外れます。
次にブラウザのコンソールで ChunkLoadError の有無を見ます。あれば、犯人はデプロイとチャンク名の食い違いです。そして error.tsx に固定文言の「500」が残っていないか、自動リロードが入っているかを確認します。
私自身、表示を鵜呑みにして最初の数時間をキャッシュ調査に溶かしました。表示されたエラーコードと、実際のHTTPステータスは別物だと意識するだけで、切り分けは一気に速くなります。
関連して、ネットワーク起因の見分け方はClaude Code のネットワーク接続エラーの切り分けに、Workers のバンドル設計はContent Split Architecture で 62 MiB 制限を回避するにまとめてあります。
同じ「気まぐれな500」に悩んでいる方の、切り分けの一歩目になれば幸いです。