TL;DR(3行)
- Next.js(Cloudflare Workers)の
app/layout.tsxに<script src="/oc.js" defer />を 1行足しただけで、動的ルートが HTTP 503(Cloudflare error 1102 = exceededCpu) を返すようになった。- トップページは200のままだった。 だからデプロイ直後の全数検査(106/106 合格)でも、死活監視でも捕まらなかった。
- 犯人は 同一URLに20リクエストずつ投げる4版のA/B で確定した。肝は 「変更前を自分で再ビルドした対照版」を必ず取ること。
環境
| 項目 | 値 |
|---|---|
| サイト | 方言の変換・クイズサイト(106ページ) |
| フレームワーク | Next.js(App Router)/React 19 |
| ホスティング | Cloudflare Workers(Free プラン) |
| 症状が出た面 |
/translate/[slug]・/quiz/[slug] などの動的ルート |
| 症状が出なかった面 | トップページ、静的な法的ページ |
| 変更内容 |
app/layout.tsx に <script src="/oc.js" defer /> を1行追加 |
やりたかったことは地味で、「計測用のJSを1本、全ページで読み込む」だけだった。
AdSense のタグを素の <script src> で置いていて問題が出ていなかったので、同じ書き方をもう1本足した。React 19 は <script> を勝手に <head> へ持ち上げてくれる(リソース・ホイスト)ので、置き場所は気にしなくていい ── そう思っていた。
1. 起きたこと
1-1. デプロイ直後の全数検査は「106/106 合格」だった
このサイトは、全URLに計測タグが入っているかを毎回スクリプトで全数検査している。デプロイ後に走らせて 106面すべて合格。ここで作業を終えていた。
1-2. しばらくして /translate/* が高頻度で 503 を返し始めた
HTTP/2 503
(Cloudflare error code 1102)
/translate/okinawa を叩くと、5回に1回どころではない頻度で503が返る。同じ形の /quiz/[slug] も同様だった。ところが、
1-3. トップページは 200 のままだった
これが一番きつかった点。死活監視は普通トップページを見る。 そしてトップは何事もなく200を返し続けた。
「サイトが落ちた」ではなく「サイトの一部の面だけが、確率的に落ちる」ので、見ている場所が1cmずれているだけで永久に気づけない。
同じ理由で、デプロイ直後の全数検査もすり抜けた。全数検査は各URLを1回ずつ叩く。確率的に落ちるものは、1回ずつの検査では捕まらない。「全ページ200でした」は、「常に200である」の証拠ではない。
2. 原因の特定
2-1. wrangler tail が一発で答えを出した
推測する前に、本番のログを見る。
npx wrangler tail --format=json
返ってきたのがこれ。
{
"outcome": "exceededCpu",
"cpuTime": 10,
"exceptions": [{ "message": "Worker exceeded CPU time limit." }]
}
exceededCpu。メモリでもタイムアウトでもなく、CPU 時間の超過。
2-2. Workers Free の CPU 上限は 10ms/リクエスト
cpuTime: 10 がそのまま上限値だった。Free プランは 1リクエストあたり 10ms の CPU 時間(壁時計時間ではなく、実際に計算に使った時間)。I/O 待ちは含まれないので普段は余裕があるが、SSR は素で CPU を使う。
つまりこのサイトは、事故の前から 常に上限のすぐ手前で走っていた。1行の追加は、最後のひと押しでしかない。
2-3. なぜ <script src> 1行で CPU が足りなくなるのか(ここから先は仮説)
⚠️ ここは実測ではなく、自分の理解を書いています。 実測で確定したのは次の章のA/Bまで ──「素の <script src> を足すと503が出て、next/script なら出ない」であって、その中で何ミリ秒がどこに消えたかは計測していません(cpuTime の内訳を取る手段を持っていない)。
自分が読んでいる筋はこう。React 19 は、コンポーネントツリーのどこに書かれた <script src> でも <head> へ巻き上げる(リソース・ホイスト)。この巻き上げはクライアントの都合ではなく、サーバー側のレンダリング中に処理される。
- 素の
<script src>→ SSR のレンダリング経路に処理が乗る -
next/script(afterInteractive)→ クライアント側で挿入されるので、SSR の CPU は増えない
普段なら誤差の差だと思う。上限まで 1ms しか余っていない Worker では、その誤差が致命傷になった ── というのが今の理解。別の説明ができる人がいたら教えてください。
3. 犯人を確定させる ── 20リクエスト × 4版のA/B
「たぶんこの変更が原因」で終わらせると、次に同じことをする。なので実測で確定させた。
3-1. 版の行き来は wrangler で数分
Cloudflare Workers は過去バージョンに戻せる。ロールバックできる=A/Bが取れる、というのがここでの一番の武器だった。
npx wrangler deployments list # 版の一覧
npx wrangler rollback <version-id> # 直前の版へ戻す
npx wrangler versions deploy <version-id>@100% # 任意の版を100%に載せる
3-2. 測り方
同一URL(/translate/okinawa)・同条件で、各版に 20リクエストずつ、5秒間隔。
probe() { # probe <url> <n>
local ok=0 ng=0
for i in $(seq 1 "${2:-20}"); do
c=$(curl -s -m 8 -o /dev/null -w '%{http_code}' "$1")
[ "$c" = "200" ] && ok=$((ok+1)) || ng=$((ng+1))
sleep 5
done
echo "200=$ok other=$ng"
}
3-3. 結果
| 版 | 200 | 503 |
|---|---|---|
変更前の layout(wrangler rollback で復帰) |
20 | 0 |
素の <script src="/oc.js" defer />(事故版) |
6 | 14 |
| 変更前の layout を、自分で再ビルドした対照版 | 20 | 0 |
<Script src="/oc.js" strategy="afterInteractive" />(採用) |
20 | 0 |
事故版だけが 20回中14回 503。他の3版は 20/20 で 200。
3-4. 🔑 3行目の「対照版」がこの表の主役
1行目と2行目だけでも「変更が原因」と言いたくなる。でもそれだと、次の反論を潰せない。
- 「たまたまその時間帯だけ Cloudflare 側が不調だったのでは?」
- 「ロールバックで戻した版は別のビルド成果物だから、比較になっていないのでは?」
- 「そもそも
<script>とは無関係に、ビルドし直したら直っただけでは?」
3行目は「変更前のソースを、事故版と同じ手順でビルドし直して載せた版」。これが 20/20 で200を返したことで、ビルド行為そのものは無罪だと確定する。残る差分は <script> の1行だけになる。
「直った」を「原因が分かった」と取り違えないための唯一の道具が対照版。
直し方だけ持って帰ると、半年後に同じ地雷を踏む。
3-5. n=20 で足りるのか(正直に)
足りない。事故版が 6/20 なら差は明らかだが、「10回に1回落ちる」を検出するには 20 は少なすぎる。
今回は差が極端だったので 20 で判定した、というだけ。小さい差を否定する用途には使えない(後述の第5章がまさにそれ)。
4. 直し方
4-1. next/script に戻す
// app/layout.tsx
import Script from 'next/script'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<body>
{children}
{/* 素の <script src> は SSR の CPU を食う。afterInteractive はクライアント挿入 */}
<Script src="/oc.js" strategy="afterInteractive" />
</body>
</html>
)
}
4-2. 「直った」だけでなく「壊していない」まで確認する
503 が消えても、計測が動かなくなっていたら意味がない。本番のDOMでクリックを1回発火させ、イベントが 1クリックにつき1件(二重送信なし)で飛ぶところまで見た。
実際に踏んで確認した面がこれ:方言ラボのブログ記事 / 沖縄方言の変換ページ(後者が事故で落ちていた面そのもの)。
4-3. 副次で踏んだ落とし穴 2つ
① 全数検査ツール自体が、事故の燃料だった。
キャッシュを完全に外して全URLを取り直すモード(--fresh)は、106面をまるごとSSRさせる。HTMLは no-store なのでエッジキャッシュも効かない。つまり検査を連発するほど CPU 上限を押し上げる。測定器が対象を壊すタイプのやつ。
② ブラウザ自動操作で「イベントが1件も出ない」と誤判定した。
navigate の直後に JS 評価を並べていたので、defer スクリプトの実行前に検証コードが走っていた。壊れているのはサイトではなく測り方だった。navigate と評価の間に待ちを挟む。
5. この記事を書いている今日、落ちているのは別の面だった
ここからが「直しました、めでたし」で終わらない部分。
5-1. 実測(2026-09-03・記事執筆中に取得)
同じ probe を、修正済みの本番に対して回した。
| URL | 200 | 503 | 応答なし(8秒タイムアウト) |
|---|---|---|---|
/translate/okinawa(事故で落ちていた面) |
20 / 20 | 0 | 0 |
/(トップ)1回目 |
16 / 20 | 2 | 2 |
/(トップ)2回目 |
14 / 20 | 2 | 4 |
/quiz/tosa(公開直後に追加で測った) |
10 / 12 | 2 | 0 |
事故のときと 役割がそっくり入れ替わっている。あのとき「200だから健康」の証拠として使っていたトップページが、いま不安定な側になっている。
しかも最後の1行は、この記事を公開した直後に足したもの。本文中のリンクが生きているか確認しようとして /quiz/tosa を叩いたら、いきなり503が返ってきた。測った面しか分からないという話を書いている記事で、測っていなかった面がもう1つ出てきた。
5-2. わかっていないことを、わかっていないまま書く
- トップの 503 が 同じ
exceededCpuかどうかは未確認。wrangler tailを当てていないので、原因は書けない - 8秒で応答が返らなかった 6件(
000)が、遅いSSRなのか、こちら側のネットワークなのかも切り分けていない - したがって「修正が不十分だった」とも「別件だ」とも言えない。言えるのは「今日は トップが40回中4回、
/quiz/tosaが12回中2回、200を返さなかった」だけ - 母数も足りない。
/quiz/tosaは12回しか投げていないので、「17%落ちる」と読むのは早い(3-5 に書いたとおり)
書けるのはここまで。**この記事の主張は「原因はこうだ」ではなく、「トップが200であることを健康の証拠に使うな」**なので、結論としてはむしろ都合が悪いほど正しい。
6. 持ち帰り用チェックリスト
CPU 上限の近くで走っている SSR に何かを足すとき、自分がこれから毎回やること:
- トップページの200を、健康の証拠にしない。 一番重い動的ルートを名指しで測る
- 1回叩いて200は「200を返すことがある」。 確率的な障害は 同一URLに20回
- 原因を言い切る前に対照版を取る。 「変更前のソースを、同じ手順で再ビルドして載せた版」
-
推測の前にログ。 Workers なら
wrangler tailでoutcomeを見れば即決する -
SSR 経路に素の
<script src>を置かない。 React 19 のホイストは SSR 中に走る - 直した後、壊していないかまで確認する。 503が消えても計測が死んでいたら意味がない
- 測定器が対象に負荷をかけていないか。 全数スイープは連発しない
7. 参考
- Cloudflare Workers Limits(CPU time) ── Free の CPU 上限が書かれている一次情報
-
Cloudflare Workers エラーコード一覧 ──
1102の定義 - wrangler rollback / versions deploy ── A/B を数分で回すための道具
-
next/script のドキュメント ──
strategyの違い -
React の
<script>/ リソース読み込み ── ホイストの挙動
同じサイトを並列のAIエージェントで触っていて別の壊し方をした話を、前に書きました → Claude Codeのサブエージェント6体を同じリポジトリで並走させたら、成果物を消し合った
「0」の読み違いを7件並べた回はこちら → 外部リンク73本が全部未計測だった
この記事の数字は、すべて自分のサイトの本番で実測したものです。 図はその実測値から起こしました。
再現条件(Workers Free + Next.js App Router + React 19 + 重めのSSR)が揃っている人は、まず一番重い動的ルートに20回 curl を投げてみてください。 うちはそれで「一部の面だけが確率的に落ちている」ことに、しばらく気づいていませんでした。





