🐙

「なんでこの順で動くの?」を一発で理解する:JS/TSのイベントループと実行順(同期→Microtask→描画→Macrotask)

に公開1

非同期処理のコードは書けるけど、実行順に自信がない。
awaitsetTimeoutPromise.then が どの順で動くかが直感とズレる。
この記事は、そんな人のために イベントループと「tick」 を軸に、実行順のルールをスッキリ言語化します。


この記事のゴール

  • イベントループの流れを、同期→Microtask→描画→Macrotask の順で理解する
  • await(= 非同期の続きがいつ動く?)/ Promise.then / setTimeout実行順が説明できるようになる

用語の解説

  • Call Stack(コールスタック)
    「今、実行中の関数の山」。同期コードはここで一気に走る。

  • Queues(キュー)
    「後で実行する処理」の待ち行列。

    • Microtask QueuePromise.then/catch/finallyqueueMicrotaskawait の“続き”
    • Macrotask (Task) QueuesetTimeout, setInterval, setImmediate(Node) など
  • 描画(ブラウザ)
    レイアウト/ペイント。Microtask を処理した後に行われる。

  • tick(ティック)
    イベントループ1周のこと。
    1 tick の中で「同期→Microtask全消化→(必要なら)描画→Macrotaskを1つ実行」までが行われ、次の tick に進む。


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

  • tick(ティック) … イベントループの 1 周 の単位
  1. Macrotask を 1 つ取り出して実行
    この“中身”として 同期コードが Call Stack で走る。
  2. Microtask Queue を空になるまで全部実行
    Promise.then/catch/finally、await の続き、queueMicrotask など。
  3. (ブラウザ)必要なら描画(レイアウト/ペイント)
  4. 次の tick へ(次の Task/Macrotask を 1 つ実行)
console.log("A"); // ← Tick N の macrotask(同期)

setTimeout(() => console.log("timeout (macro)"), 0); // ← 次の macrotask(= Tick N+1)

Promise.resolve()
  .then(() => console.log("microtask 1")) // ← Tick N の“末尾”で実行
  .then(() => console.log("microtask 2"));

console.log("B"); // ← Tick N の macrotask(同期)

最短で実行順を体感する例

console.log("A");

setTimeout(() => console.log("timeout (macro)"), 0);

Promise.resolve()
  .then(() => console.log("microtask 1"))
  .then(() => console.log("microtask 2"));

console.log("B");

出力A → B → microtask 1 → microtask 2 → timeout (macro)

  • A,B は同期なので即実行(Call Stack)
  • 同じ tick の末尾Microtask全部実行(then-1then-2
  • その次の tickMacrotasksetTimeout)を実行

await はいつ動く?——「関数の続き」が Microtask で再開

async function main() {
  console.log("A");
  const res = await fetch("/api/user"); // ← ここで「この関数の続き」を一時停止
  console.log("B");                     // ← 解決後、Microtask として再開
}
main();
console.log("C");

出力A → C → (fetch完了後)B

  • awaitPromise が解決するまで「その関数の続き」だけ一時停止
  • 解決したら 続きが Microtask としてキューに入る今の tick が終わったタイミングで再開
  • スレッド全体は止まらない(UIは固まらない)

await非同期を同期“っぽく”書ける糖衣構文。裏側はイベントループ+Microtask が仕事しているだけ。


「Microtask は同期の一番最後?」「setTimeout は次の tick?」に答える

  • Microtask今の tick の末尾で、空になるまで全部実行される
    → 体感的には「同期の最後」に動く

  • setTimeout(..., 0) のコールバック(Macrotask):
    次の tick で実行される(Microtask より後)

つまり、「今すぐこの tick の最後に突っ込みたい」→ Microtask
「次のタイミング(次の tick)でいい」→ Macrotask


「Call Stack と Microtask が並列で走る瞬間は?」→ ない

  • JS の実行は基本 1スレッド
  • 同時実行はしない
    同期(Call Stack)を終える → Microtask を全部処理 → Macrotask …と順番に切り替えるだけ。

Node.js には I/O 用のスレッドプールがあるが、JS本体の実行は常に1スレッド


2つのキューの動作をまとめて確認

console.log("A");

setTimeout(() => console.log("[macro] timeout"), 0); // 次の tick

Promise.resolve()
  .then(() => console.log("[micro] then-1"))
  .then(() => console.log("[micro] then-2"));        // 同じ tick の末尾

queueMicrotask(() => console.log("[micro] qMicro")); // 同じ tick の末尾

console.log("B");

/*
A
B
[micro] then-1
[micro] then-2
[micro] qMicro
[macro] timeout
*/
  • 同期A, B
  • Microtask(今の tick の末尾で全消化):then-1then-2qMicro
  • Macrotask次の tick):timeout

実務チートシート

  • 今の tick の末尾にすぐ入れたい
    queueMicrotask(fn) / Promise.resolve().then(fn)Microtask
  • 次のタイミング(次の tick)でよい
    setTimeout(fn, 0) / setImmediate(Node)Macrotask
  • 描画直前や毎フレームで実行したい(ブラウザ)
    requestAnimationFrame(fn)

まとめ

  • 1 tick同期 → Microtask(全消化)→ 描画 → Macrotask(1つ)
  • Microtask今の tick の末尾でまとめて動く
  • setTimeout次の tick
  • 並列実行はしない(JS本体は1スレッド)。順番に切り替えるだけ

実行順のルールが腹落ちすると、非同期コードの “なぜこの順?” が消えて、デバッグも設計も一段ラクになります。ここを土台に、Promise.all などの描画最適化なども攻めていきましょう。

Discussion

junerjuner

「Call Stack と Microtask が並列で走る瞬間は?」→ ない

WebWorker 等の Worker の利用が別途必要ですね。(Promise 返す様にして触りやすい関数作ったりしますが)

https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Using_web_workers

https://nodejs.org/api/worker_threads.html

その為、 thread 間でオブジェクトが共有されるとまずいので structuredClone で コピーして送信する。
コピーコストが高い ArrayBuffer 等は structuredClone の第二引数により 移譲して そのまま送信したりしてコスト低減を試みる