TITLE

「特定ユーザーだけ画面が固まる」——Cloudflare Workers の hanging promise から学ぶエッジランタイム設計の注意点

投稿日:2026.07.06

CATEGORY

  • Web開発
「特定ユーザーだけ画面が固まる」——Cloudflare Workers の hanging promise から学ぶエッジランタイム設計の注意点

「特定のユーザーだけ、ブラウザで何をしても画面が固まる」——他のユーザーは問題なく使えているのに、そのユーザーだけは全リクエストが応答待ちのまま固まり、直すにはブラウザを再起動するしかない。原因の絞り込みが難しい典型的な障害パターンです。

こうした障害を Cloudflare Workers + better-auth の構成で実際に経験し、原因を突き止めて Zenn に報告したのが、エンジニアの coji氏です。coji氏はこの現象を「hanging promise(ハングした Promise)」と呼んでいますが、これは coji氏の報告記事で使われている表現であり、Cloudflare の公式ドキュメントが定義した用語ではありません。

なぜこの障害は厄介だったのか

coji氏の報告によれば、まず疑われたのはクライアント側、つまりブラウザやネットワークの問題でした。ところが、ネットワークログを分析した結果はその予想を裏切るものでした。問題が起きているユーザーのリクエストは、実際にはサーバーへ到達しており、HTTP/2 の接続自体も健全だったのです。

つまり「クライアントからリクエストが送られていない」わけでも、「接続が切れている」わけでもない。リクエストは届いているのに、応答が返ってこないまま止まる。この、クライアント側の症状とサーバー側の健全性が矛盾して見える組み合わせこそが、この障害を再現も特定も難しくしていた理由です。原因は、クライアントとサーバーの間ではなく、サーバー内部の「処理の生存期間」という、普段は意識しない場所にありました。

原因: リクエストが切断されても、Promise は生き続ける

isolate内でPromiseが固まり後続リクエストを巻き込む状態と、別isolateで正常に処理されるリクエストを対比した抽象図

Cloudflare Workers には、coji氏の報告によると、リクエスト処理中に生成された Promise はクライアントが途中で切断しても pending(未解決)状態のまま残り続ける、という動作特性があると報告されています。電話を切ったつもりが、相手側では喋り続けている——そんな「Promise の幽霊」が isolate(Workers のランタイムがリクエストごとに割り当てる実行環境)の中に取り残される、というイメージです。リクエストの結線が切れたからといって、その中で生まれた非同期処理が自動的に破棄されるとは限らない、という話です。

この特性が、認証ライブラリ better-auth(coji氏の報告時点のバージョンは 1.6.20)の実装と組み合わさったときに問題化しました。better-auth には、遅延初期化(実際に必要になった最初のタイミングで初期化を行う仕組み)が複数箇所に存在していました。具体的には、AsyncLocalStorage(リクエストをまたいで状態を引き回すための Node.js の仕組み)をモジュールスコープでキャッシュする Promise と、ライブラリのインスタンス内部に保持される、もう一つの初期化用の Promise です。

問題は、この初期化を最初に駆動するリクエストが、初期化の途中でクライアントに切断された場合に起きます。切断された時点でその Promise は完了しないまま残り、しかも同じ isolate 内で以降に届く認証まわりの全リクエストが、その未完了の Promise を待ち続けることになります。つまり、たった1つの切断されたリクエストが、その isolate 全体の認証処理を巻き添えにして止めてしまうわけです。

ここで、影響が「特定ユーザーだけ」に見えたことにも説明がつきます。coji氏の報告によれば、Cloudflare Workers では同一の接続は同じ isolate にルーティングされます。同じ接続を使い続けるユーザーは、いわば同じ部屋に閉じ込められている状態です。初期化を巻き込んで固まった isolate という「部屋」に居合わせたのは、たまたまその接続を使い続けていた特定のユーザーだけだった、ということです。他のユーザーは別の isolate(別の部屋)で処理されるため、まったく影響を受けません。

3層の対策の考え方

coji氏の報告では、この問題に対して3層の対策が組まれています。それぞれ、リクエストという単位に縛られた初期化・処理をどう扱うか、という一貫した視点でつながっています。

1層目: 初期化をリクエストに紐づけない場所に置く

AsyncLocalStorage をグローバルスコープで事前にセットしておく、という対策です。モジュールの読み込み時に実行される処理は特定のリクエストに属さないため、一般的にはどのリクエストが途中で中断されても影響を受けません。そもそもリクエストの生存期間に依存する初期化を、依存しない場所へ動かしてしまう、という考え方です。

2層目: 残る初期化の寿命を、切断後も「延長コード」でつなぐ

1層目だけでは解消しきれない遅延初期化については、ctx.waitUntil に処理を係留する対策が取られています。Cloudflare の公式ドキュメントによると、waitUntil() を呼び出すと、HTTP トリガーの Worker の場合、レスポンスの送信やクライアントの切断後も最大30秒間、実行を継続できる猶予が与えられます。電源(処理)を切断後も一定時間だけ保つ、いわば延長コードのような仕組みです。この猶予を使って、クライアントが切断した後でも初期化そのものを完了させてしまう、という対策です。

3層目: それでも応答がなければ、「保険のタイムアウト」で見切りをつける

getSession(セッションの有効性を確認する処理)に3秒のハング検知が実装されています。3秒経っても応答がなければ、まずインスタンスの再構築を試み、それでも応答できない場合は、未認証の状態として処理を続行します。初期化の完了を待ち続けるのではなく、一定時間で見切りをつけて、決められた挙動(この場合は未認証扱い)に倒す、という安全弁です。2層目の「延長コード」で救えなかった場合の、最後の保険と言えます。

なお、Cloudflare の公式ドキュメントは waitUntil() について、複数回の呼び出しが可能であり、Promise.allSettled と同様に、1つの呼び出しに渡した Promise が失敗しても他の呼び出しの実行には影響しないと説明しています。一方で、この「延長コード」にも用途の限界はあります。たとえば Durable Objects については、通常のリクエストやRPC処理の中で Durable Object を生かし続ける目的で waitUntil() を使うべきではない、という注記が同じドキュメントにあります。延命の仕組みには、それぞれ想定された用途と範囲がある、ということです。

まとめ: リクエスト境界をまたぐ状態を洗い出す

ここまでをまとめると、今回の障害そのものは Cloudflare Workers と better-auth という特定の組み合わせで起きた事例です。ただし、そこから浮かび上がる問い——「リクエストという単位で実行環境を区切るアーキテクチャでは、そのリクエストの中で生まれた非同期処理(Promise やキャッシュの初期化など)は、リクエストの終了(あるいは切断)と同時にきれいに片付くとは限らない」——は、他のサーバーレス/エッジランタイムを検討・採用する際にも一度は確かめておく価値がある視点だと私は考えています。

一言で言うと、ポイントは「初期化やキャッシュのような、複数のリクエストにまたがって使い回されるはずの状態を、たまたま最初に来たリクエストの生存期間に縛りつけていないか」を確認することです。縛りつけていれば、そのリクエストが中断されたときに、後続のリクエストまで巻き添えにする可能性があります。

サーバーレス/エッジランタイムの採用を検討する際、私は機能や価格だけでなく、こうした「リクエストが本当に終わったと言える条件は何か」というランタイム固有のライフサイクル仕様まで確認しておくべきだと考えています。特に認証まわりのように複数リクエストで共有される初期化処理を扱うライブラリを組み込む場合、そのライブラリがリクエストの生存期間をどう扱っているかは、性能や機能一覧には出てこない観点です。


出典

AI

AI導入やシステム開発の ご相談を承っています。

お気軽にお問い合わせください