
JavaScriptの非同期とイベントループ - Promise・async/await・マイクロタスクの順序を理解する
setTimeout と Promise.then のどちらが先に動くか、即答できますか。JavaScriptの非同期はイベントループという1本の軸で動いており、ここを押さえると「なぜこの順番で出力されるのか」がすべて説明できます。この記事では、MDN と Node.js 公式を一次ソースに、仕組みとよくある落とし穴をコード例で整理します。
イベントループの基本モデル
JavaScriptはシングルスレッドで、関数は最後まで走り切る(run-to-completion)のが原則です。登場人物は3つ。
- コールスタック: いま実行中の関数の積み重ね(LIFO)
- タスクキュー(マクロタスク):
setTimeoutやイベントのコールバックが並ぶ(FIFO) - マイクロタスクキュー:
Promise.thenやqueueMicrotaskが並ぶ。タスクより高優先
ブラウザのイベントループは、おおむね次のサイクルを繰り返します。
- タスクを1つ取り出して実行し、スタックが空になるまで走らせる
- マイクロタスクキューを「空になるまで」全部処理する(処理中に増えた分も同じ回で処理)
- 必要ならレンダリング(画面更新)
- 次のタスクへ(1に戻る)
ポイントは、タスクは1回に1つなのに対し、マイクロタスクは空になるまで一気に処理される点です。
マクロタスクとマイクロタスク
| 種別 | 代表例 |
|---|---|
| マクロタスク | setTimeout / setInterval / ユーザーイベント / I/O / スクリプトの初回評価 |
| マイクロタスク | Promise.then/catch/finally / queueMicrotask() / MutationObserver |
実際に順序を確かめます。
console.log("1: sync start");
setTimeout(() => console.log("4: setTimeout (macro)"), 0);
queueMicrotask(() => console.log("3: microtask"));
console.log("2: sync end");
// 出力:
// 1: sync start
// 2: sync end
// 3: microtask
// 4: setTimeout (macro)同期コードが先、次にマイクロタスク、最後にマクロタスク。setTimeout(fn, 0) でもマイクロタスクより後になるのが要点です。
定番の「順番当て」
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve()
.then(() => console.log("C"))
.then(() => console.log("D"));
console.log("E");
// 出力: A → E → C → D → BA・E は同期。C のあとに D のマイクロタスクが積まれ、同じマイクロタスク消化フェーズで D まで片付きます。B(マクロタスク)はその後の回です。
Promiseの基礎
Promiseは非同期の結果を表すオブジェクトで、状態は一度確定すると変わりません。
- pending(未確定)→ fulfilled(成功)または rejected(失敗)
.then(onFulfilled)/.catch(onRejected)/.finally(onFinally)でチェーン- チェーン途中で throw / reject すると、下流の
.catch()まで伝播する
コンビネータの違い
複数のPromiseをまとめる4つは、混同しやすいので表で。
| メソッド | 成功する条件 | 失敗する条件 | 戻り値 |
|---|---|---|---|
Promise.all | 全部成功 | 1つでも失敗(即座に) | 結果の配列 |
Promise.allSettled | 常に成功(失敗しない) | なし | 各要素の {status, value/reason} |
Promise.race | 最初に確定したのが成功 | 最初に確定したのが失敗 | 最初に確定した値 |
Promise.any | 1つでも成功 | 全部失敗 | 最初の成功値(全滅時 AggregateError) |
「全部の結果が欲しい・失敗も知りたい」なら allSettled、「どれか1つ成功すれば良い」なら any、と覚えると迷いません。
async/await の本質
async/await はPromiseの糖衣構文です。難しく考えず、次の2点を押さえます。
async関数は常にPromiseを返す(戻り値は自動でPromise.resolve相当にラップ)awaitは.thenと同じくマイクロタスク。await以降は解決後にマイクロタスクとして再開する
async function fetchUser(url) {
try {
const res = await fetch(url);
return await res.json();
} catch (e) {
console.error("取得失敗:", e);
}
}直列と並列(よくあるアンチパターン)
独立した非同期処理を await で順番に書くと、無意味に直列化されて遅くなります。
// NG: 2秒 + 1秒 = 約3秒かかる
const a = await wait(2000);
const b = await wait(1000);
// OK: 同時に走らせ、最長の約2秒で完了
const [a2, b2] = await Promise.all([wait(2000), wait(1000)]);依存関係がない呼び出しは Promise.all で並列化するのが基本です。
よくある落とし穴
forEach の中の await は効かない
// NG: forEach は中の Promise を待たず、"done" が先に出る
urls.forEach(async (url) => {
const data = await fetch(url); // 待たれない
});
console.log("done");
// OK(直列): for...of なら await が効く
for (const url of urls) {
const data = await fetch(url);
}
// OK(並列): map + Promise.all
await Promise.all(urls.map((url) => fetch(url)));その他の定番
awaitの付け忘れ:saveToDb(data)をawaitせず放置すると、失敗しても気づけない(未処理のrejected)- エラーの握りつぶし:
.catch(() => {})で握り潰さず、最低でもログを出す - 無限マイクロタスク: マイクロタスクの中でマイクロタスクを延々と積むと、レンダリングに到達できずUIが固まる
WARNING
マイクロタスクはサイクル内で「空になるまで」処理されます。再帰的に積み続けると、ステップ3の画面更新フェーズへ進めずブラウザが固まります。重い反復はマクロタスク(例: setTimeout で分割)に逃がすか、requestIdleCallback などを検討します。
ブラウザとNode.jsの違い
Node.jsのイベントループは libuv 由来のフェーズ(timers → pending → poll → check → close など)で構成されます。実務で効くのは次の点です。
setImmediateとsetTimeout(fn, 0): I/Oコールバックの内側ではsetImmediateが必ず先。I/Oの外では順序は非決定的process.nextTick: イベントループの一部ではなく、各フェーズの合間にマイクロタスクより先に処理される特別なキュー
おおまかな優先順位(CommonJS)は次のとおりです。
| 優先 | キュー |
|---|---|
| 1 | process.nextTick キュー |
| 2 | Promiseのマイクロタスク(queueMicrotask 含む) |
| 3 | 次のイベントループフェーズ(timers / poll など) |
NOTE
ESモジュール(ESM)では、トップレベルが既にマイクロタスク消化中として扱われるため、queueMicrotask と process.nextTick の相対順が CommonJS と変わる場合があります。モジュール方式・バージョン依存なので、順序に依存した設計は避けるのが無難です。Node.js最新の前提はNode.js 26 と Temporalも参照。
まとめ
- 非同期はイベントループで動く。タスクは1回に1つ、マイクロタスクは空になるまで一気に処理し、その後レンダリング
setTimeout(マクロ)よりPromise.then(マイクロ)が先- Promiseは確定したら不変。
all/allSettled/race/anyを使い分ける async/awaitはPromiseの糖衣。独立処理はPromise.allで並列化、forEach内awaitは効かない- Node.jsは
process.nextTickがマイクロタスクより先。順序依存の設計は避ける
「同期 → マイクロタスク → (描画)→ マクロタスク」という1本の流れさえ頭に入れば、非同期の出力順で悩むことはほぼなくなります。


