ウェブページを駆動するJavaScriptのイベントループは、Node.jsサーバーを動かすものとは大きく挙動が異なります。注意を怠ると、この違いによってUIがフリーズしたり、I/Oが停滞したりすることがあります。両方の環境で動作する非同期コードを書く人にとって、両者がどこで分岐するのかを知っておくことは不可欠です。

なぜその違いが重要なのか

イベントループはECMAScriptの仕様で定義されているわけではなく、ホスト環境に存在します。ブラウザはフレームのレンダリングを行いながらページの応答性を維持しなければなりませんが、Node.jsはノンブロッキングI/Oを中心に構築されています。一方のホストで機能するパターンをもう一方に混ぜて使うと、再現が困難なバグを引き起こす可能性があります。例えば、長いPromiseの連鎖はブラウザの再描画(repaint)を停止させる可能性があり、一方でチェックされていないprocess.nextTickのループは、Node.jsがI/Oフェーズに到達するのを妨げる可能性があります。

ブラウザのターンベース・ループ

ブラウザでは、ループはタスクの実行、マイクロタスクの消化、およびレンダリングを交互に行う単一のサイクルを実行します。

  1. 1つのマクロタスクを実行する(クリックハンドラー、setTimeoutなど)。
  2. すべてのマイクロタスクを消化する(Promise、queueMicrotask)。
  3. フレームのタイミングであれば、目標の60fpsに合わせるためにペイントとコンポジットを行う。
  4. 繰り返す。

2つのAPIが、開発者にこのサイクルへの明示的なフックを提供します。

  • requestAnimationFrame – ブラウザがペイントする直前に呼び出されます。コールバックは現在のマイクロタスクの後に、次のフレームの前に実行されるため、アニメーション処理に適しています。
  • requestIdleCallback – ブラウザに優先度の高い作業がないときに呼び出されます。アナリティクスやデータのプリロードなど、影響の少ないタスクに有用です。

落とし穴:マイクロタスクによる飢餓(starvation)

ブラウザはレンダリングを行うにマイクロタスクキューを空にするため、長いPromiseの連鎖があるとUIがいつまでもペイントされなくなります。コールスタックがブロックされているわけではなく、ページが単にレンダリングステップに到達できない状態になり、ユーザーにはフリーズしたように感じられます。

Node.jsのlibuv駆動ループ

Node.jsは、作業をそれぞれ独自のキューを持つ明確なフェーズに分割するC言語のライブラリであるlibuvにループを委ねています。

  1. TimerssetTimeoutsetIntervalからのコールバック。
  2. Pending callbacks – OSレベルですでに完了している、遅延されたI/Oコールバック。
  3. Poll – 新しいI/Oイベント(ファイルの読み込み、ネットワークデータ)を取得。
  4. ChecksetImmediateのコールバックを実行。
  5. Close callbacks – ソケットやハンドルが閉じられたときに実行。

このフェーズ順序の外側に位置する2つの構成要素があります。

  • process.nextTick – 現在の操作が終了した直後、マイクロタスクキューのに実行されます。

落とし穴:I/Oの飢餓(starvation)

関数が制御を譲ることなく(yielding)繰り返しprocess.nextTickをスケジュールすると、Node.jsは「next-tick」ステップから先に進めなくなります。ネットワークリクエスト、ファイルの読み込み、タイマーなどが待機状態となり、サーバー側のレイテンシの急増や、完全なハングアップを引き起こします。

実践における setImmediate vs. setTimeout

両者とも次のイテレーションでコールバックをスケジュールしますが、その相対的な順序は呼び出し場所によって異なります。

  • トップレベルのコード – 順序は保証されず、プロセスの起動速度に依存します。
  • I/Oコールバック内 – 順序は決定的です:setImmediatesetTimeout(fn, 0)よりも先に実行されます。Pollフェーズが終了した後、libuvは、遅延ゼロのタイムアウトのためにTimersフェーズに再入する前に、Checkフェーズ(setImmediateが存在する場所)に移動するためです。

この微妙な違いは、読み込み完了直後にリソースをクリーンアップするなど、正確なシーケンスに依存する場合に重要となります。

一目でわかる主な違い

  • 目的: ブラウザは視覚的な更新を優先し、Node.jsはI/Oの準備完了を優先します。
  • レンダリングのフック: requestAnimationFrame(ブラウザのみ)。
  • フェーズ固有のフック: setImmediate(Nodeのみ、Checkフェーズで実行)。
  • 高優先度キュー: process.nextTick(Nodeのみ、マイクロタスクの前に実行)。
  • 飢餓のリスク: ブラウザでは長いPromiseの連鎖、Nodeでは無制限のprocess.nextTick

次に注意すべきこと

両方の環境で動作するコードベース(例:アイソモーフィックなライブラリ)を保守している場合は、以下の箇所を監査してください。

  • イベントループに制御を譲ることなく、多くのPromiseを連鎖させている箇所。await new Promise(r => setTimeout(r, 0))を挿入するか、ブラウザではrequestIdleCallbackを使用してレンダラーにチャンスを与えてください。
  • 遅延可能な作業に対してprocess.nextTickを使用している箇所。「next-tick」レベルの緊急性が必要ない場合は、setImmediateまたは通常のPromiseを優先してください。
  • setTimeout(fn, 0)setImmediateが互換性があると想定している箇所。シーケンスが重要な場合は、I/Oコールバック内での順序をテストしてください。

まとめ

イベントループはホスト固有のスケジューラであり、JavaScriptの普遍的な機能ではありません。ブラウザはレンダリングをループに組み込み、NodeはI/Oをlibuvのフェーズに分離します。優先順位メカニズム(ブラウザにおけるmicrotasksやNodeにおけるprocess.nextTick)を誤用すると、各環境が本来担うべきシステムの機能を阻害する可能性があります。非同期パターンをホストのループモデルに合わせることで、ページのフリーズやサーバーのブロッキングの両方を回避できます。