イベントループ実行モデルの状態遷移図
なぜ「Promise が setTimeout より先」「アニメーションがカクつく」が起きるのか、1ティックの順序を追えば腑に落ちる。実行モデルを状態遷移として文章で図解します。
- 1.イベントループの1ティックは「タスクを1個取り出して実行→マイクロタスクを空になるまで全消化→必要なら描画フェーズ(rAF→スタイル/レイアウト/ペイント)→暇ならアイドルコールバック」という決まった順序で進む。
- 2.マイクロタスク(Promise/queueMicrotask)はタスク(setTimeout/イベント)より優先され、ティックの境目で“完全に”消化される。だから Promise.then は setTimeout(0) より必ず先に走る。
- 3.requestAnimationFrame は描画の直前、requestIdleCallback はフレームに余裕がある時だけ呼ばれる。レイアウト値の読み書きを混ぜると強制同期レイアウトでカクつく。
イベントループとは「1個ずつ回す」仕組み
JavaScript はブラウザのメインスレッドで動くシングルスレッドの言語です。にもかかわらず、クリック処理・タイマー・通信完了・アニメーションを同時にこなせるのは、イベントループがそれらを1個ずつ順番に取り出して回しているからです。
重要なのは、この回し方が HTML 仕様の Event loop processing model として厳密に決まっていることです。「なんとなく非同期」ではなく、毎ティック同じ順序で状態が遷移する。その順序を頭に入れると、Promise.then が setTimeout より先に走る理由も、requestAnimationFrame でアニメーションする理由も、すべて同じ1枚の図から説明できます。
キューは2種類ある:タスクとマイクロタスク
まず押さえるべきは、待ち行列が2種類あることです。両者は優先度も消化のされ方も違います。
| タスクキュー(マクロタスク) | マイクロタスクキュー | |
|---|---|---|
| 代表例 | setTimeout / setInterval / イベント / メッセージ | Promise.then / await の続き / queueMicrotask / MutationObserver |
| 1ティックで処理する数 | 原則1個だけ取り出す | 空になるまで全部 |
| 消化のタイミング | ティックの先頭 | タスク実行直後など“チェックポイント”ごと |
| 優先度 | 低い | 高い(タスクより先に割り込む) |
ここで「タスクは1ティックに1個、マイクロタスクは空になるまで全部」という非対称が、後述の挙動すべての源になります。
1ティックの状態遷移
1回のループ(1ティック)は、おおむね次の順で状態が移ります。上から下へ一方向に進み、最後はまたタスクの取り出しへ戻る、という閉じたループです。
[1] タスクを1個取り出して実行
↓
[2] マイクロタスクを“空になるまで”全消化 ← await/then はここで連鎖
↓
[3] このティックで描画する? ──いいえ──→ [1] へ戻る
↓ はい(描画タイミングが来た)
[4] requestAnimationFrame コールバック実行
↓
[5] スタイル計算 → レイアウト → ペイント
↓
[6] 余裕があれば requestIdleCallback 実行
↓
[1] へ戻る
ポイントは2つです。第一に、[2] のマイクロタスク消化は途中で止まらない。消化中に新しいマイクロタスクが積まれたら、それも同じ [2] の中で消化します。第二に、描画([3]〜[5])は毎ティック起きるとは限らない。ディスプレイのリフレッシュ(多くは約16.7ms間隔)に合わせて「描画する番のティック」でだけ走ります。タイマーが連射されても、描画は間引かれるわけです。
なぜ Promise は setTimeout より先なのか
この順序が分かると、定番のクイズが機械的に解けます。
console.log('A: 同期');
setTimeout(() => console.log('D: タイマー(タスク)'), 0);
Promise.resolve().then(() => console.log('C: マイクロタスク'));
console.log('B: 同期');
出力は A → B → C → D です。理由はこうです。まず同期コード(現在のタスク本体)が走り A、B を出します。setTimeout はタスクキューへ、then はマイクロタスクキューへ積まれるだけ。同期コードが終わってタスクが空になると [2] に入り、マイクロタスクを全消化して C。setTimeout のコールバックは次のティックの [1] でようやく取り出され D。つまり「setTimeout(0) は0msではなく“最低でも次ティック以降”」が正確な理解です。
[2] は「空になるまで」消化します。then の中でさらに Promise.resolve().then(...) を積み続けると、キューが永遠に空にならず [3] 以降に到達できません。結果、描画もイベント処理も止まり、ページが完全に固まります(タブが応答なしに)。タイマーの再帰と違い、マイクロタスクの再帰は描画を一切挟まない点が危険です。
描画フェーズ:rAF →スタイル/レイアウト/ペイント
描画する番のティックでは、まず requestAnimationFrame(rAF) のコールバックがスタイル計算より前に呼ばれます。ここが rAF の存在意義です。「次のフレームを描く直前」に DOM をいじれるので、変更がそのフレームに確実に反映され、かつ1フレームに1回へ自然に間引かれます。
その直後に スタイル計算 → レイアウト → ペイント → 合成 が走ります(この後半パイプラインの詳細は ブラウザのレンダリングの仕組み を参照)。順序が「rAF が先、レイアウトが後」である点が実務上きわめて重要です。
rAF はスタイル計算の前に走ります。そのコールバック内で offsetWidth や getBoundingClientRect() のようなレイアウト依存の値を読むと、ブラウザは正しい値を返すためにその場でレイアウトを前倒し実行します(強制同期レイアウト=レイアウトスラッシング)。さらに直後に書き込み、また読む…を繰り返すと毎回レイアウトが走り、フレーム予算(約16.7ms)を簡単に食い潰してカクつきます。**「読みを全部済ませてから、書きを全部行う」**の分離が鉄則です。
// ❌ 読み→書き→読み… が交互。rAF内でレイアウトを何度も強制
function bad() {
for (const el of items) {
const w = el.offsetWidth; // 読む(レイアウトを強制)
el.style.width = w + 10 + 'px'; // 書く(次の読みでまた強制)
}
requestAnimationFrame(bad);
}
// ✅ 先に全部読む → 後で全部書く
function good() {
const ws = items.map(el => el.offsetWidth); // 読みをまとめる
items.forEach((el, i) => { // 書きをまとめる
el.style.width = ws[i] + 10 + 'px';
});
requestAnimationFrame(good);
}
アイドルフェーズ:requestIdleCallback
描画まで終えて、次のフレームまでにまだ時間が余っているとき、ブラウザは requestIdleCallback に登録されたコールバックを呼びます。引数で渡される deadline.timeRemaining() は「あと何ミリ秒の余裕があるか」で、これを見ながら重くない仕事を少しずつこなすのが正しい使い方です。
向くのは「急がないが、いつかやりたい」処理です。ログのバッチ送信、プリフェッチ、アナリティクスの集計など。逆にDOM の更新や入力応答をここに置くのは禁物で、余裕がなければ何分も呼ばれないことがあります。締め切りを保証したいなら timeout オプションを付けます。
setTimeout は「指定時間後(最短でも次ティック)にタスクとして」、requestAnimationFrame は「次の描画の直前に1回」、requestIdleCallback は「フレームに余裕がある暇な時だけ」。アニメーションは rAF、緊急の割り込みは Promise/マイクロタスク、後回しでよい雑務は rIC、と層が分かれています。
マイクロタスクはどこで消化されるか
「マイクロタスクはタスクの直後」と書きましたが、より正確にはコールスタックが空になった各チェックポイントで消化されます。タスク本体の終了時はもちろん、[4] の各 rAF コールバックの後など、仕様が定める区切りごとに「マイクロタスクが残っていれば全消化」が挟まります。だから await の続きは、わざわざ次のタスクを待たず、できるだけ早い区切りで再開されます。
出題されやすいのは「実行順序の予測」です。覚える軸は3つ。(1) 同期コードが最優先で最後まで走る。(2) その後マイクロタスク(Promise)を全消化。(3) 次ティックでタスク(setTimeout)を1個。async/await の await 以降はマイクロタスク扱いなので setTimeout(0) より先、という引っかけが頻出です。Node.js では process.nextTick がマイクロタスクよりさらに先、という違いも問われます。
まとめ
イベントループの1ティックは 「タスク1個 → マイクロタスク全消化 → (描画する番なら)rAF → スタイル/レイアウト/ペイント → 余裕あればアイドル」 という固定の状態遷移を繰り返します。タスクは1ティック1個、マイクロタスクは空になるまで全部、という非対称が「Promise は setTimeout より先」の正体です。描画フェーズでは rAF がレイアウトの前に走るため、ここでレイアウト値を読むと強制同期レイアウトでカクつきます。この1枚の順序図を持っておけば、非同期の挙動は JavaScript の知識と合わせてほぼ説明でき、Web パフォーマンス の改善にも直結します。土台となる DOM 操作のコストも、この図の上で考えると判断しやすくなります。
Web/フロントエンド Article
イベントループ実行モデルの状態遷移図を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
マイクロタスク(Promise/queueMicrotask)はタスク(setTimeout/イベント)より優先され、ティックの境目で“完全に”消化される。だから Promise.then は setTimeout(0) より必ず先に走る。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / イベントループ」に近いか確認する。
- 強みである「イベントループの1ティックは「タスクを1個取り出して実行→マイクロタスクを空になるまで全消化→必要なら描画フェーズ(rAF→スタイル/レイアウト/ペイント)→暇ならアイドルコールバック」という決まった順序で進む。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。