C
TypeScript/Async/Lesson 02

Event Loop — Microtask vs Macrotask, Expressing Queues with Types

30 min·theory
This chapter
2/7
TypeScript

Event Loop — Microtask vs Macrotask, Expressing Queues with Types

💡 Why Should You Learn This? — See the Queue, See the Bug

🎯 Why Promise executes before setTimeout: the microtask queue takes priority over the macrotask queue. A classic interview question.
💼 TypeScript does not change the queues themselves, but it can enforce the signatures of functions placed into those queues. Mistakes such as passing the wrong callback are caught at compile time before they ever enter the queue.
TypeScript knows the exact callback signatures for `queueMicrotask`, `setImmediate`, and `requestAnimationFrame`, which prevents incorrect call patterns.
🔗 Ordering bugs in asynchronous code (race conditions) cannot be caught by the type system — but understanding the queue model is prerequisite to debugging them.
🏢 실무에서는
React's `useEffect`, Next.js's `useTransition`, Vue's `nextTick` — all of them sit on top of the microtask/macrotask queue model. Why you get a stale value when you read the DOM immediately after `setState`, and why props can change between two `await` lines — it all comes down to the event loop model.

Call Stack → Web API → Queue → Loop

1. The 4 Components of the Event Loop

ComponentRole
Call StackStack of functions currently executing
Web API / Node APIHost-provided async workers such as setTimeout, fetch, and fs.readFile
Macrotask QueueThe line where setTimeout, setInterval, setImmediate, and I/O callbacks wait
Microtask QueueThe line where Promise.then, queueMicrotask, and MutationObserver callbacks wait

2. One Cycle of the Loop

1. When the Call Stack is empty, drain the entire Microtask Queue.
2. Then pull exactly one item from the Macrotask Queue.
3. Drain the Microtask Queue again.
4. Repeat.

ts
console.log('A');                                  // 1
setTimeout(() => console.log('B'), 0);             // macrotask
Promise.resolve().then(() => console.log('C'));    // microtask
console.log('D');                                  // 2
// Output: A → D → C → B  (sync → microtask → macrotask)

3. What TypeScript Protects

ts
// The callback signature for queueMicrotask is () => void
queueMicrotask(() => console.log('hi'));   // ✅
// queueMicrotask((x: number) => console.log(x)); // ❌ TS blocks this — callback must take no arguments

// setTimeout callbacks can accept extra arguments (TS knows this)
setTimeout((name: string) => console.log(name), 1000, 'Hong Gil-dong');
//                                                     ^^^^^^^ passed as the first callback argument

4. Where async/await Meets the Queue

ts
async function f() {
  console.log('1');
  await Promise.resolve(); // function pauses here
  console.log('2');        // this line enters the microtask queue
}
f();
console.log('3');
// Output: 1 → 3 → 2
// Code after await is always scheduled as a microtask continuation
💻 🅰️ JavaScript Style — Queue Model Simulation (No Types)
// ❌ JS — Even if queues are expressed, types are poor

const microtasks = []; // Don't know what goes in
const macrotasks = [];

function queueMicro(fn) { microtasks.push(fn); } // Signature of fn?
function queueMacro(fn) { macrotasks.push(fn); }

function runLoop() {
  // Drain all microtasks
  while (microtasks.length) microtasks.shift()();
  // One macrotask
  if (macrotasks.length) macrotasks.shift()();
}

queueMacro(() => console.log('B: macrotask'));
queueMicro(() => console.log('A: microtask'));
console.log('start');
runLoop();
runLoop();
// Output: start → A → B
💻 🅱️ TypeScript Style — Explicitly Typing the Queue
// ✅ TS — Expressing queues with types

type Task = () => void;

const microtasks: Task[] = [];
const macrotasks: Task[] = [];

function queueMicro(fn: Task): void { microtasks.push(fn); }
function queueMacro(fn: Task): void { macrotasks.push(fn); }

function runLoop(): void {
  while (microtasks.length) {
    const task = microtasks.shift();
    task?.(); // shift result is Task | undefined — TS forces narrowing
  }
  const next = macrotasks.shift();
  next?.();
}

queueMacro(() => console.log('B: macrotask'));
queueMicro(() => console.log('A: microtask'));
// queueMicro('not a function'); // ❌ TS immediately rejects — not a Task
// queueMicro((x: number) => x); // ❌ Different from Task signature () => void

console.log('start');
runLoop();
runLoop();

// Real browser APIs work on the same principle
queueMicrotask(() => console.log('real microtask'));
setTimeout((name: string) => console.log('macrotask:', name), 0, 'Hong Gil-dong');
//          ^^^^^^^^^^^^^ TS knows setTimeout's variadic argument signature

💡 💡 Event Loop Interview Classics + Mistakes TypeScript Catches

1. Why Promise runs before setTimeout(0)
Microtask queue priority: within a single cycle, all microtasks are drained before one macrotask is processed.

2. Code after await is always a microtask

ts
async function f() {
  console.log('A');
  await Promise.resolve(); // function pauses here
  console.log('B');        // scheduled as microtask continuation
}

3. queueMicrotask callbacks take no arguments — TypeScript enforces this

ts
queueMicrotask(() => console.log('ok'));        // ✅
// queueMicrotask((x: number) => console.log(x)); // ❌

4. TypeScript knows about setTimeout's extra arguments

ts
setTimeout((a: number, b: number) => console.log(a + b), 0, 1, 2);
// TS verifies that callback args (a, b) map to the 3rd and 4th args of setTimeout (1, 2)

5. An infinite microtask loop freezes the main thread

ts
function infinite() { Promise.resolve().then(infinite); } // 🚨 browser hangs

TypeScript cannot catch this — block it in code review.

⚡ Try It Yourself — Event Loop Order

Verify the order: sync → microtask → macrotask with your own eyes.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Check Your Understanding

What is the output order of the following code? ```ts console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D'); ```
💡 Synchronous code (A, D) runs first → once the Call Stack is empty, drain the microtask queue (C) → then one macrotask (B). Result: **A → D → C → B**. The key insight is that microtasks always run before macrotasks.
Event Loop — Microtask vs Macrotask - TypeScript