イベントループの内部構造(タスクキューとマイクロタスク)
なぜ Promise はsetTimeoutより先に走るのか、その理由がはっきり分かる。タスク・マイクロタスク・レンダリングの順序を内部アルゴリズムのレベルで解き明かします。
- 1.イベントループは1反復ごとに「マクロタスクを1個実行→マイクロタスクを空になるまで全消化→必要ならレンダリング」を回す。Promise はマイクロタスクなので setTimeout より必ず先に走る。
- 2.queueMicrotask と Promise の then/catch/finally はマイクロタスクキューへ。setTimeout・イベント・I/O はマクロタスク(タスク)キューへ。両者は別キューで優先度が違う。
- 3.requestAnimationFrame はマイクロタスク消化後・スタイル計算とペイントの直前に走る描画専用コールバック。アニメーションは setTimeout ではなく rAF を使うのが正しい。
1つのスレッドで、すべてをさばく
JavaScript の実行モデルは シングルスレッドです。1つのコールスタックしか持たず、ある関数が動いている間、他のコードは割り込めません。それでもクリック・タイマー・通信・アニメーションを同時にさばけるのは、ブラウザが イベントループ(event loop) という調停役を回しているからです。
イベントループの仕事は単純で、「実行すべき仕事(タスク)を、決まった順序で1つずつコールスタックに載せる」だけです。ポイントは、この順序が WHATWG HTML 仕様で厳密に決められたアルゴリズムであること。setTimeout と Promise.then のどちらが先に走るかは、気分や運ではなく、仕様で一意に決まります。本記事はその順序を内部レベルで解きほぐします。基礎は JavaScript を前提にします。
2種類のキュー:タスクとマイクロタスク
イベントループが参照するキューは大きく2系統あります。この区別がすべての出発点です。
- タスクキュー(マクロタスク / task):1反復で最大1個だけ取り出して実行する。
setTimeout/setIntervalのコールバック、DOM イベント(click 等)、fetch完了の通知、<script>の実行などが入る。 - マイクロタスクキュー(microtask):タスクを1個実行し終えた直後に、空になるまで全部を連続実行する。
Promiseのthen/catch/finally、queueMicrotask()、MutationObserverのコールバックが入る。
| 種類 | 代表的な発生源 | 1反復での処理数 | 実行タイミング |
|---|---|---|---|
| タスク(マクロ) | setTimeout / イベント / I/O完了 / メッセージ | 1個だけ | 反復の先頭 |
| マイクロタスク | Promise then・catch・finally / queueMicrotask / MutationObserver | 空になるまで全部 | タスク直後・描画前 |
HTML 仕様に「マクロタスク」という語は出てきません。仕様上は単に task と呼び、それと対になる microtask があるだけです。日常会話で microtask と区別するために慣習的に「マクロタスク」と呼んでいる、と理解しておくと混乱しません。
イベントループの1反復(アルゴリズムの骨子)
仕様のイベントループ処理を、要点だけ疑似コードにすると次のようになります。地の文では集合記法を {task, microtask} のようにインラインコードで書きます(裸の波括弧は MDX が壊すため)。
loop:
// 1) タスクを1つ取り出して実行(なければスキップ)
task = タスクキューから1個取り出す
if task: run(task) // ここで同期的にスタックが空になるまで実行
// 2) マイクロタスクを“空になるまで”全消化
while マイクロタスクキューが空でない:
m = 取り出す
run(m) // 実行中に追加された分も同じループで消化される
// 3) 必要ならレンダリング機会(描画する反復のみ)
if 描画するタイミング:
run requestAnimationFrame コールバック群
スタイル計算 → レイアウト → ペイント
goto loop
ここから導かれる鉄則は2つです。第一に、1反復でマクロタスクは1個、マイクロタスクは全部。第二に、マイクロタスクが残っている限り、次のマクロタスクにもレンダリングにも進まない。この非対称性が、実務で観測される順序のほぼすべてを説明します。
マイクロタスクは「空になるまで」消化されるため、マイクロタスク内でさらにマイクロタスクを無限に積むと、ループがそこから抜けられません。次のタスクもレンダリングも永久に来ず、画面が固まります。再帰的な queueMicrotask や Promise チェーンの無限自己生成は、setTimeout(…, 0) による分割(=次のタスクへ譲る)に置き換えるのが安全です。
なぜ Promise は setTimeout より先に走るのか
この有名な挙動は、上のアルゴリズムからそのまま導けます。
console.log('A'); // 同期
setTimeout(() => console.log('B'), 0); // タスクキューへ
Promise.resolve().then(() => console.log('C')); // マイクロタスクキューへ
console.log('D'); // 同期
// 出力順: A → D → C → B
順を追うと、まず現在のタスク(このスクリプト全体)が同期的に走り、A・D が出ます。この時点で B はタスクキュー、C はマイクロタスクキューに積まれています。スクリプトというタスクが終わった直後、ステップ2でマイクロタスクが全消化されるので C が先に出ます。B(次のタスク)はその後の反復まで待たされる――だから setTimeout(…, 0) でも Promise には勝てません。
async/await の await は、内部的には Promise の then 相当です。await foo() の後ろのコードはマイクロタスクとして再開されます。つまり await を挟んだ続きは、同じ反復のマイクロタスクフェーズで走り、setTimeout より先に進みます。await を「同期的に止まる」とイメージすると順序を読み違えます。
requestAnimationFrame のタイミング
requestAnimationFrame(rAF)は、マクロタスクでもマイクロタスクでもありません。イベントループのステップ3(レンダリング機会)の冒頭、スタイル計算・レイアウト・ペイントの直前に呼ばれる、描画専用のコールバック群です。
- 画面更新の直前に1回だけ呼ばれるので、ここで DOM を変更すれば、その変更が同じフレームのペイントに間に合う。
- 呼び出し頻度はディスプレイのリフレッシュレートに同期する(多くは毎秒60回)。タブが非アクティブだと呼ばれないため、無駄な描画やバッテリ消費を避けられる。
// ❌ setTimeout でアニメーション:描画周期とズレてカクつく/裏タブでも回る
setTimeout(function loop() { move(); setTimeout(loop, 16); }, 16);
// ✅ rAF:ペイント直前に呼ばれ、リフレッシュレートに同期する
requestAnimationFrame(function loop() { move(); requestAnimationFrame(loop); });
setTimeout のタイマーは「最低でもこの時間後」を保証するだけで、描画周期とは無関係です。だからフレームと位相がずれてカクつきます。アニメーションや「DOM 変更を次の描画に確実に反映させたい」用途では rAF が正解です。レンダリング工程そのものは ブラウザのレンダリングの仕組み を参照してください。
全部を1つの時系列に並べる
ここまでの要素を、ボタンクリックを起点に1反復へ落とし込むと、優先順位がはっきりします。
| 順序 | フェーズ | ここで走るもの |
|---|---|---|
| 1 | タスク実行 | click ハンドラ本体(同期コードはここで完走) |
| 2 | マイクロタスク全消化 | ハンドラ内の Promise.then / await の続き / queueMicrotask |
| 3 | レンダリング機会 | requestAnimationFrame → スタイル → レイアウト → ペイント |
| — | 次反復へ | 次のタスク(別の setTimeout 等)を1個取り出す |
button.addEventListener('click', () => {
console.log('1: ハンドラ(タスク)');
setTimeout(() => console.log('5: 次のタスク'), 0);
requestAnimationFrame(() => console.log('4: 描画直前 rAF'));
Promise.resolve().then(() => console.log('3: マイクロタスク'));
console.log('2: ハンドラ内の同期コード末尾');
});
// クリック時の出力: 1 → 2 → 3 → 4 → 5
同期コード(1・2)がハンドラというタスク内で完走し、続いてマイクロタスク(3)、レンダリング機会の rAF(4)、そして次の反復でようやく別タスク(5)――この並びは仕様どおりで、ブラウザ間でぶれません。
「同期 → マイクロタスク → (描画)→ マクロタスク」の優先順位は定番の出題です。具体的には ❶ Promise.then は setTimeout(…,0) より先、❷ await の後続はマイクロタスク、❸ マイクロタスクは1反復で全部・マクロタスクは1個、❹ requestAnimationFrame はペイント直前で then とは別系統――この4点を押さえれば実行順の問題はほぼ解けます。
Node.js との違い(混同しないために)
ブラウザと Node.js はどちらもイベントループを持ちますが、実装が別です。Node は libuv 由来の複数フェーズ(timers / pending / poll / check / close)を持ち、setImmediate(check フェーズ)や process.nextTick(マイクロタスクよりさらに優先される独自キュー)があります。ブラウザには setImmediate も process.nextTick も標準では存在しません。本記事のアルゴリズムは**ブラウザ(WHATWG HTML 仕様)**のものとして読んでください。共通するのは「同期 → マイクロタスク → 次のタスク」という大枠だけで、細部は環境依存です。
まとめ
イベントループは1反復ごとに「マクロタスクを1個 → マイクロタスクを全部 → 必要ならレンダリング」を回します。Promise.then・await の続き・queueMicrotask はマイクロタスクなので、setTimeout(…,0) などのマクロタスクより必ず先に走ります。requestAnimationFrame はそのどちらでもなく、ペイント直前に呼ばれる描画専用フェーズです。順序を「同期 → マイクロタスク → 描画 → 次のマクロタスク」と覚えれば、非同期コードの実行順はほぼ説明できます。実際の描画コストは Web パフォーマンス と合わせて確認すると、なぜ rAF やマイクロタスクの扱いが効くのかが腑に落ちます。
Web/フロントエンド Article
イベントループの内部構造(タスクキューとマイクロタスク)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
queueMicrotask と Promise の then/catch/finally はマイクロタスクキューへ。setTimeout・イベント・I/O はマクロタスク(タスク)キューへ。両者は別キューで優先度が違う。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / イベントループ」に近いか確認する。
- 強みである「イベントループは1反復ごとに「マクロタスクを1個実行→マイクロタスクを空になるまで全消化→必要ならレンダリング」を回す。Promise はマイクロタスクなので setTimeout より必ず先に走る。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。