JavaScriptの非同期とイベントループ - Promise・async/await・マイクロタスクの順序を理解する

JavaScriptの非同期とイベントループ - Promise・async/await・マイクロタスクの順序を理解する

作成日:
読了:11
更新日:

setTimeoutPromise.then のどちらが先に動くか、即答できますか。JavaScriptの非同期はイベントループという1本の軸で動いており、ここを押さえると「なぜこの順番で出力されるのか」がすべて説明できます。この記事では、MDN と Node.js 公式を一次ソースに、仕組みとよくある落とし穴をコード例で整理します。

イベントループの基本モデル

JavaScriptはシングルスレッドで、関数は最後まで走り切る(run-to-completion)のが原則です。登場人物は3つ。

  • コールスタック: いま実行中の関数の積み重ね(LIFO)
  • タスクキュー(マクロタスク): setTimeout やイベントのコールバックが並ぶ(FIFO)
  • マイクロタスクキュー: Promise.thenqueueMicrotask が並ぶ。タスクより高優先

ブラウザのイベントループは、おおむね次のサイクルを繰り返します。

  1. タスクを1つ取り出して実行し、スタックが空になるまで走らせる
  2. マイクロタスクキューを「空になるまで」全部処理する(処理中に増えた分も同じ回で処理)
  3. 必要ならレンダリング(画面更新)
  4. 次のタスクへ(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 → B

AE は同期。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.any1つでも成功全部失敗最初の成功値(全滅時 AggregateError

「全部の結果が欲しい・失敗も知りたい」なら allSettled、「どれか1つ成功すれば良い」なら any、と覚えると迷いません。

async/await の本質

async/awaitPromiseの糖衣構文です。難しく考えず、次の2点を押さえます。

  • async 関数は常にPromiseを返す(戻り値は自動で Promise.resolve 相当にラップ)
  • await.then と同じくマイクロタスクawait 以降は解決後にマイクロタスクとして再開する
await のエラー処理は try/catch
async function fetchUser(url) {
  try {
    const res = await fetch(url);
    return await res.json();
  } catch (e) {
    console.error("取得失敗:", e);
  }
}

直列と並列(よくあるアンチパターン)

独立した非同期処理を await で順番に書くと、無意味に直列化されて遅くなります。

直列(遅い) vs 並列(速い)
// 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 は効かない

forEach は Promise を待たない
// 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 など)で構成されます。実務で効くのは次の点です。

  • setImmediatesetTimeout(fn, 0): I/Oコールバックの内側では setImmediate が必ず先。I/Oの外では順序は非決定的
  • process.nextTick: イベントループの一部ではなく、各フェーズの合間にマイクロタスクより先に処理される特別なキュー

おおまかな優先順位(CommonJS)は次のとおりです。

優先キュー
1process.nextTick キュー
2Promiseのマイクロタスク(queueMicrotask 含む)
3次のイベントループフェーズ(timers / poll など)

NOTE

ESモジュール(ESM)では、トップレベルが既にマイクロタスク消化中として扱われるため、queueMicrotaskprocess.nextTick の相対順が CommonJS と変わる場合があります。モジュール方式・バージョン依存なので、順序に依存した設計は避けるのが無難です。Node.js最新の前提はNode.js 26 と Temporalも参照。

まとめ

  • 非同期はイベントループで動く。タスクは1回に1つ、マイクロタスクは空になるまで一気に処理し、その後レンダリング
  • setTimeout(マクロ)より Promise.then(マイクロ)が先
  • Promiseは確定したら不変all/allSettled/race/any を使い分ける
  • async/await はPromiseの糖衣。独立処理は Promise.all で並列化forEachawait は効かない
  • Node.jsはprocess.nextTick がマイクロタスクより先。順序依存の設計は避ける

「同期 → マイクロタスク → (描画)→ マクロタスク」という1本の流れさえ頭に入れば、非同期の出力順で悩むことはほぼなくなります。

参考リンク

TypeScript 6.0 の新機能と破壊的変更 - JavaScript製で書かれる最後のコンパイラ

TypeScript 6.0 の新機能と破壊的変更 - JavaScript製で書かれる最後のコンパイラ

10

TypeScript 6.0(2026年3月23日リリース)を公式アナウンスとリリースノートをもとに整理します。6.0 が「JavaScript製の最後のコンパイラ」であり 7.0(Go製・Project Corsa)へのブリッジである位置づけ、this を使わない関数の推論改善・#/ サブパスインポート・es2025 lib・Temporal 型などの新機能、そして strict や module/target の既定値変更、amd/umd や outFile の削除、module 名前空間構文や import assert の廃止といった破壊的変更、ignoreDeprecations や stableTypeOrdering による移行までを一次ソースでまとめます。

Node.jsが年1回のメジャーリリースへ - Node 27からの新スケジュールとLTSの考え方

Node.jsが年1回のメジャーリリースへ - Node 27からの新スケジュールとLTSの考え方

7

Node.js が 2026年3月の公式発表でリリーススケジュールを変更します。Node 27(2027年)から、メジャーは年1回(毎年4月)に集約され、奇数/偶数の区別を廃止して全リリースが LTS 対象に。早期テスト用の Alpha チャンネル新設、総サポート期間の36か月化など、新旧スケジュールの違いと、Node 26 / Node 27 の位置づけ、運用への影響を Node.js 公式ブログを一次ソースに整理します。