The Event Loop: Spec-Level Definitions of Job Queue and Task Queue
JavaScript's single-threaded engine handles network requests, user clicks, and timers not through parallelism, but through a precise scheduling protocol called the Event Loop. Understand it and you understand the true nature of all JavaScript "asynchrony."
๐น Level 1 ยท What You Need to Know
The JavaScript engine is single-threaded: only one piece of code executes at any given moment. The illusion of concurrency comes from the Event Loopโa continuous cycle that checks task queues, dequeues tasks, and executes them.
Two Task Categories: Macro-tasks and Micro-tasks
| Dimension | Macro-task (Task) | Micro-task (Microtask) |
|---|---|---|
| Aliases | Task Queue, Message Queue | Job Queue, Microtask Queue |
| Sources | setTimeout, setInterval, I/O callbacks, UI render events, postMessage |
Promise.then, queueMicrotask, MutationObserver (browser) |
| Execution timing | One per event loop tick | All flushed after the current task ends |
| Can produce new tasks | Yes, appended to the queue | Yes, new microtasks execute within the current flush |
| Spec source | HTML spec ยง8.1.6 | ECMAScript spec ยง9.5 (Jobs) |
The most critical rule: After every macro-task completes, the engine flushes the entire microtask queue to emptyโincluding microtasks generated by other microtasksโbefore executing the next macro-task (or a render frame).
Priority Mnemonic
Sync code > Microtasks (all) > Render > Macro-task (one) > Microtasks (all) > Render > Macro-taskโฆ
Basic Output Prediction Exercise
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('micro'));
console.log('end');
// Output: start โ end โ micro โ timeout
Explanation:
console.log('start')โ synchronous, runs immediatelysetTimeoutโ registers a macro-task into the Task QueuePromise.resolve().thenโ registers a microtask into the Microtask Queueconsole.log('end')โ synchronous, runs immediately- Sync code finishes; flush microtask queue โ
micro - Execute next macro-task โ
timeout
Common Misconceptions
Misconception 1: setTimeout(fn, 0) executes immediately
setTimeout(fn, 0) means "put this into the Task Queue no earlier than 0 ms from now," but it must wait until the current task and all microtasks finish. The actual minimum delay in browsers is typically 1โ4 ms (the HTML spec mandates a 4 ms throttle after 5+ nested levels).
Misconception 2: Microtasks and macro-tasks are both defined by JavaScript spec
The ECMAScript spec only defines "Jobs" (micro-tasks). The "Task Queue" and the overall event loop model are defined by the HTML spec (WHATWG). Node.js has its own implementation via libuv.
Misconception 3: There is only one microtask queue per loop
Browsers may maintain multiple macro-task queues (rendering, network, user input, etc.) and a scheduler decides which to pull from. However, there is exactly one microtask queue, and it must be fully drained after every macro-task.
Node.js Special Queues
Node.js adds two queues beyond standard ECMAScript microtasks:
| Queue | API | Priority |
|---|---|---|
| nextTick queue | process.nextTick |
Higher than Promise microtasks |
| Promise microtasks | Promise.then |
Normal microtask |
| Timers phase | setTimeout, setInterval |
Macro-task (libuv phase 1) |
| Check phase | setImmediate |
Macro-task (libuv phase 5) |
Node.js execution order: sync โ nextTick โ Promise microtasks โ macro-tasks (by libuv phase).
๐ธ Level 2 ยท How It Actually Works
Complete Event Loop Execution Model
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Event Loop Tick โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Step 1: Dequeue one Task from Task Queue โ โ
โ โ (wait for new tasks if queue is empty) โ โ
โ โโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Step 2: Execute that Task (run JS code) โ โ
โ โ Call stack: empty โ active โ empty โ โ
โ โโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Step 3: Drain Microtask Queue โ โ
โ โ while (microtaskQueue.length > 0) { โ โ
โ โ task = microtaskQueue.shift() โ โ
โ โ task() // may enqueue new microtasks โ โ
โ โ } โ โ
โ โโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Step 4: Rendering opportunity (browser only) โ โ
โ โ if (renderNeeded && frameTimeReached) { โ โ
โ โ render(); โ โ
โ โ } โ โ
โ โโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโ Back to Step 1 โโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The ECMAScript Job Mechanism
ECMAScript Chapter 9 (Executable Code and Execution Contexts) defines the Job concept.
A Job Queue is a FIFO list identified by name. The spec defines two built-in queues:
PromiseJobs โ callbacks from Promise .then/.catch/.finally
ScriptJobs โ eval() evaluation (rarely encountered in practice)
The EnqueueJob abstract operation:
EnqueueJob(queueName, job, arguments):
1. Assert queueName is a known queue name
2. Create a PendingJob record:
{ [[Job]]: job, [[Arguments]]: arguments,
[[Realm]]: currentRealm, [[ScriptOrModule]]: currentScript }
3. Append that record to the end of the named queue
4. Return unused
When Promise.resolve().then(callback) executes, it internally calls EnqueueJob("PromiseJobs", PromiseReactionJob, [reaction, value]).
Tracking Call Stack, Task Queue, and Microtask Queue
setTimeout(() => console.log('T1'), 0);
Promise.resolve()
.then(() => {
console.log('M1');
Promise.resolve().then(() => console.log('M2'));
});
console.log('sync');
| Step | Call Stack | Microtask Queue | Task Queue | Output |
|---|---|---|---|---|
| Initial | [main] |
[] |
[] |
โ |
| setTimeout runs | [main, setTimeout] |
[] |
[T1_cb] |
โ |
| Promise.then registers | [main] |
[M1_cb] |
[T1_cb] |
โ |
| console.log('sync') | [main, log] |
[M1_cb] |
[T1_cb] |
sync |
| main ends, stack empty | [] |
[M1_cb] |
[T1_cb] |
โ |
| Run M1_cb | [M1_cb] |
[] |
[T1_cb] |
M1 |
| Inner Promise.then registers | [M1_cb] |
[M2_cb] |
[T1_cb] |
โ |
| M1_cb ends | [] |
[M2_cb] |
[T1_cb] |
โ |
| Run M2_cb | [M2_cb] |
[] |
[T1_cb] |
M2 |
| Microtasks empty; run macro-task | [T1_cb] |
[] |
[] |
T1 |
Output order: sync โ M1 โ M2 โ T1
async/await Microtask Expansion
async function foo() {
console.log('foo start');
await Promise.resolve();
console.log('foo after await');
}
console.log('before');
foo();
console.log('after');
Desugared equivalent:
function foo() {
console.log('foo start');
return Promise.resolve(undefined).then(() => {
console.log('foo after await');
});
}
Execution trace:
beforeโ syncfoo startโ sync (inside foo body)awaitsuspends, registers microtask for the continuationafterโ sync (foo is paused, control returns to caller)- Sync code ends; flush microtasks โ
foo after await
Output: before โ foo start โ after โ foo after await
Node.js libuv Event Loop Phases
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Node.js Event Loop โ
โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
โ โ timers โโ โ pending โโ โ idle, โ โ
โ โsetTimeoutโ โcallbacks โ โ prepare โ โ
โ โsetInterval โ โ โ (internal) โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
โ โ โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
โ โ close โโ โ check โโ โ poll โ โ
โ โcallbacks โ โsetImmed. โ โ (I/O wait) โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
โ โ
โ After each phase: nextTick โ Promise microtasks โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Key point: Node.js drains both nextTick and Promise microtask queues after each phase, not just after each top-level macro-task.
Classic Interview Question โ Full Analysis
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function () { console.log('setTimeout'); }, 0);
async1();
new Promise(function (resolve) {
console.log('promise1');
resolve();
}).then(function () {
console.log('promise2');
});
console.log('script end');
| Step | Action | Output | Microtask Queue |
|---|---|---|---|
| 1 | console.log('script start') |
script start | [] |
| 2 | setTimeout registers |
โ | [], Task:[cb] |
| 3 | Enter async1, then async2 |
async1 start, async2 | โ |
| 4 | await suspends async1 |
โ | [async1_cont] |
| 5 | Promise constructor (sync) | promise1 | [async1_cont] |
| 6 | .then registers |
โ | [async1_cont, p2_cb] |
| 7 | console.log('script end') |
script end | โ |
| 8 | Flush: run async1_cont | async1 end | [p2_cb] |
| 9 | Flush: run p2_cb | promise2 | [] |
| 10 | Run macro-task | setTimeout | โ |
Final output: script start โ async1 start โ async2 โ promise1 โ script end โ async1 end โ promise2 โ setTimeout
๐บ Level 3 ยท How the Spec Defines It
ECMAScript Spec: ยง9.5 Jobs and Host Operations
From ECMAScript 2024, ยง9.5.1:
9.5.1 EnqueueJob ( queueName, job, arguments )
The abstract operation EnqueueJob takes arguments queueName (a String), job (a Job Abstract Closure), and arguments (a List of ECMAScript language values) and returns unused.
- Assert: queueName is "PromiseJobs".
- Let callerContext be the running execution context.
- Let callerRealm be callerContext's Realm.
- Let callerScriptOrModule be callerContext's ScriptOrModule.
- Let pending be the PendingJob Record { [[Job]]: job, [[Arguments]]: arguments, [[Realm]]: callerRealm, [[ScriptOrModule]]: callerScriptOrModule }.
- Perform any implementation-defined processing of pending.
- Add pending to the end of the Job Queue named by queueName.
- Return unused.
Key insight: The ECMAScript spec defines only EnqueueJob and the PromiseJobs queue. The question of "when to dequeue and execute" is delegated to the host environment. The HTML spec and Node.js each implement that "when" part differently.
HTML Spec: Processing Model for Event Loop
From the WHATWG HTML spec ยง8.1.7.3 (simplified pseudocode):
Run these steps forever:
1. Let taskQueue be one of the event loop's task queues,
chosen in an implementation-defined manner.
2. Let oldestTask be the first runnable task in taskQueue;
remove it from taskQueue.
3. Set the event loop's currently running task to oldestTask.
4. Run oldestTask's steps.
5. Set the event loop's currently running task to null.
6. Perform a microtask checkpoint.
7. ... (update the rendering)
Microtask Checkpoint (ยง8.1.7.4):
To perform a microtask checkpoint:
1. If performing a microtask checkpoint is true, return.
2. Set the performing a microtask checkpoint flag to true.
3. While the event loop's microtask queue is not empty:
a. Let oldestMicrotask be dequeued from the queue.
b. Set the event loop's currently running task to oldestMicrotask.
c. Run oldestMicrotask's steps.
d. Set the currently running task back to null.
4. ... (notify about rejected promises)
5. Set the performing a microtask checkpoint flag to false.
Step 3 is a while loopโthis is precisely why new microtasks generated during a microtask checkpoint are also executed within the same checkpoint.
PromiseReactionJob: The Actual Microtask Execution Unit
When a Promise resolves, the spec runs:
PromiseReactionJob ( reaction, argument ):
1. Let promiseCapability be reaction.[[Capability]].
2. Let type be reaction.[[Type]].
3. Let handler be reaction.[[Handler]].
4. If handler is empty:
a. If type is Fulfill, let handlerResult be NormalCompletion(argument).
b. Else: let handlerResult be ThrowCompletion(argument).
5. Else: let handlerResult be Completion(Call(handler, undefined, ยซ argument ยป)).
6. If promiseCapability is undefined, return unused.
7. If handlerResult is a normal completion:
return ? Call(promiseCapability.[[Resolve]], undefined, ยซ handlerResult.[[Value]] ยป).
8. Else:
return ? Call(promiseCapability.[[Reject]], undefined, ยซ handlerResult.[[Value]] ยป).
This Job is pushed via EnqueueJob("PromiseJobs", PromiseReactionJob, [reaction, value]).
๐ Level 4 ยท Edge Cases and Traps
Trap 1: Microtask Starvation of Macro-tasks
// This code permanently freezes the page
function drainAllMicrotasks() {
queueMicrotask(drainAllMicrotasks); // microtask spawns microtask, forever
}
drainAllMicrotasks();
// This setTimeout callback will never execute
// Render frames will never fire
// The page is completely frozen
setTimeout(() => console.log('will never run'), 0);
Why: The event loop cannot execute the next macro-task until the microtask queue is empty. If microtasks continuously spawn new microtasks, macro-tasks never get a chance. This is a real production trap โ even if each microtask is fast, infinite quantity equals an infinite loop.
Trap 2: Promise.resolve vs. new Promise โ Microtask Count Differences
const p = Promise.resolve(42);
Promise.resolve(p).then(v => console.log('B', v)); // same Promise, 1 microtask
new Promise(resolve => {
resolve(Promise.resolve(42)); // resolve a thenable โ extra microtask
}).then(v => console.log('C', v));
new Promise(resolve => {
resolve(42); // resolve a plain value โ 1 microtask
}).then(v => console.log('D', v));
Output order: B โ D โ C
C is last because resolve(thenable) triggers NewPromiseResolveThenableJob โ an extra microtask to unwrap the thenable โ before registering the .then callback.
Trap 3: await Microtask Count Changed Across Spec Versions
Before V8 v7.2 (Node.js < 12), await generated 2 microtasks; afterward it was optimized to 1. This changed the answers to execution-order questions.
async function test() {
await 1;
return 'done';
}
test().then(console.log);
Promise.resolve().then(() => console.log('plain micro'));
// Node 10 output: plain micro โ done (await cost 2 microtask levels)
// Node 12+ output: done โ plain micro (await costs 1 microtask level)
This was a real breaking change affecting test suites.
Trap 4: Node.js process.nextTick vs. Promise.then Ordering
Promise.resolve().then(() => console.log('promise 1'));
process.nextTick(() => console.log('nextTick 1'));
Promise.resolve().then(() => console.log('promise 2'));
process.nextTick(() => console.log('nextTick 2'));
// Output: nextTick 1 โ nextTick 2 โ promise 1 โ promise 2
Node.js drains the entire nextTick queue before touching the Promise microtask queue. Recursive process.nextTick calls can starve Promise callbacks just as infinite microtasks starve macro-tasks.
Trap 5: React 18 Automatic Batching and Microtasks
React 18 introduced automatic batching: multiple setState calls within the same event handler or async block are merged into a single render.
// Before React 18: 2 renders
setTimeout(() => {
setCount(c => c + 1); // render 1
setFlag(f => !f); // render 2
}, 1000);
// React 18: 1 render (automatic batching)
setTimeout(() => {
setCount(c => c + 1); // collected
setFlag(f => !f); // collected
// Batch processed after the macro-task callback ends
}, 1000);
React 18's Scheduler uses MessageChannel (a macro-task source) to schedule the actual render work, while state collection happens synchronously within the current macro-task. This explains why act() in tests must flush both microtasks and macro-tasks to ensure renders complete.
Summary
- Each event loop tick dequeues one macro-task, then flushes all microtasks (including ones produced by microtasks).
- The ECMAScript spec only defines PromiseJobs (microtasks); the overall event loop is defined by the HTML spec or the host runtime.
async/awaitin modern specs (ES2020+, V8 v7.2+) costs 1 microtask; older versions cost 2.- Node.js
process.nextTickhas higher priority thanPromise.then; both are "microtasks" but in separate queues. - Infinitely recursive microtasks block macro-tasks (including rendering) permanently โ a real production hazard.