JavaScript Event Loop, Task Queue, and Microtask Queue
The JavaScript event loop is the mechanism that lets JavaScript handle asynchronous work without blocking the main thread. If you understand task queues and microtask queues, you can predict the order of callbacks, promises, timers, and DOM updates with much more confidence.
Quick answer: JavaScript runs one piece of code at a time on the call stack. When that stack is empty, the event loop takes work from the task queue, but it always drains the microtask queue first. That is why promise callbacks usually run before timer callbacks.
Difficulty: Intermediate
You'll understand this better if you know: basic functions, callbacks, promises, and how synchronous code runs from top to bottom.
1. What Is the Event Loop, Task Queue, and Microtask Queue?
The event loop is JavaScript's scheduling system. It decides when queued work can run after the current synchronous code finishes.
- Call stack: where currently running JavaScript lives.
- Task queue: holds jobs such as timer callbacks and many browser events.
- Microtask queue: holds promise reactions and other high-priority deferred work.
- Event loop: watches the stack and moves queued work into execution at the right time.
In everyday use, people often say macrotask instead of task. The important idea is not the label, but that tasks and microtasks are processed at different times.
2. Why the Event Loop Matters
The event loop is what makes asynchronous JavaScript feel responsive. It allows timers, network responses, input events, and promise callbacks to happen without freezing the page or blocking other work.
It matters whenever you need to predict ordering. For example, if you mix Promise, setTimeout(), and synchronous code, the output order may surprise beginners unless they know how the queues are drained.
You also need this model to debug issues such as UI updates appearing late, timer callbacks not running when expected, or promise handlers running earlier than a timer.
3. Basic Syntax or Core Idea
How the queues work together
JavaScript executes synchronous code first. When the current stack is empty, the event loop checks the microtask queue and runs every microtask until it is empty. Only then does it take the next task from the task queue.
console.log("start"); // synchronous
setTimeout(() => {
console.log("task");
}, 0);
Promise.resolve().then(() => {
console.log("microtask");
});
console.log("end");This code prints start, end, microtask, then task. The timer callback is a task, while the promise handler is a microtask.
Queue priority at a glance
A simplified mental model is:
while (stack is not empty) {
// keep running synchronous code
}
run all microtasks
run one task
repeatThis is not exact engine source code, but it is accurate enough to predict most real-world ordering.
4. Step-by-Step Examples
Example 1: Synchronous code before everything else
Synchronous statements run immediately in the order they appear. Neither a promise callback nor a timer can interrupt them.
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");Output order: A, D, C, B. The timer waits for a task turn, but the promise callback runs in the microtask queue before the next task.
Example 2: Multiple microtasks run before the next task
Microtasks are drained completely once the current synchronous work finishes. If one microtask schedules another microtask, the new one also runs before any task.
Promise.resolve()
.then(() => {
console.log("first microtask");
return Promise.resolve();
})
.then(() => {
console.log("second microtask");
});
setTimeout(() => {
console.log("task");
}, 0);Even though the timer was scheduled immediately, both promise callbacks run first because they stay inside the microtask queue.
Example 3: queueMicrotask() creates a microtask directly
When you want to schedule work at microtask priority without creating a promise, use queueMicrotask().
console.log("before");
queueMicrotask(() => {
console.log("microtask");
});
console.log("after");This prints before, after, then microtask. The callback runs after the stack clears, but before the next task.
Example 4: A timer callback can schedule more microtasks
Each task can create new microtasks. Those microtasks run before the event loop moves on to another task.
setTimeout(() => {
console.log("task start");
Promise.resolve().then(() => {
console.log("microtask inside task");
});
console.log("task end");
}, 0);The task logs its synchronous lines first. After that task finishes, the promise reaction runs before the next timer or event callback.
5. Practical Use Cases
- Breaking up UI work so rendering can happen between chunks of logic.
- Understanding why promise-based data fetching updates state before timer-based fallback logic.
- Using queueMicrotask() to defer a callback until after the current function returns, but before the browser handles the next task.
- Debugging unexpected log order in asynchronous code.
- Reasoning about browser events, timers, and promise chains in the same function.
6. Common Mistakes
Mistake 1: Assuming setTimeout(0) runs immediately
A zero-delay timer does not mean "run now". It means "queue a task as soon as the event loop gets a chance."
Problem: The timer still waits behind synchronous code and all pending microtasks, so code that expects an immediate callback often logs in the wrong order.
console.log("start");
setTimeout(() => {
console.log("timer");
}, 0);
console.log("still synchronous");Fix: Use setTimeout() only when you truly want a task turn later. If you need work after the current call but before the next task, use queueMicrotask() or a promise reaction.
console.log("start");
queueMicrotask(() => {
console.log("microtask");
});
console.log("still synchronous");The corrected version works because microtasks run before the next task.
Mistake 2: Forgetting that promise callbacks are microtasks
Promise reactions do not wait for timers. They are scheduled as microtasks, so they usually run earlier than beginners expect.
Problem: Developers often assume a timer scheduled first will log first, but a promise callback can still run sooner because the microtask queue has higher priority.
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));Fix: Treat promise handlers as microtasks and timers as tasks. If you need to preserve an order, schedule them with the same kind of queue or chain them explicitly.
Promise.resolve()
.then(() => console.log("promise"))
.then(() => {
setTimeout(() => console.log("timer"), 0);
});The corrected version makes the dependency explicit instead of relying on queue priority guesswork.
Mistake 3: Creating an endless microtask loop
Microtasks are drained before the browser moves on to rendering or the next task. If you keep adding new microtasks inside microtasks, you can starve the event loop.
Problem: A self-perpetuating microtask chain can prevent timers, input events, and rendering from happening, making the page appear frozen.
function loop() {
queueMicrotask(loop);
}
loop();Fix: Break long or repeated work into tasks occasionally so the browser can render and respond to input.
let count = 0;
function workChunk() {
for (let i = 0; i < 1000; i++) {
count++;
}
if (count < 5000) {
setTimeout(workChunk, 0);
}
}
workChunk();The corrected version yields back to the task queue, which gives rendering and input a chance to happen.
7. Best Practices
Use microtasks for post-sync follow-up work
If you need to run code right after the current stack finishes, but before timers and other tasks, microtasks are the right fit.
queueMicrotask(() => {
// update derived state after the current function returns
});This keeps related work tightly ordered without waiting for an unnecessary timer delay.
Use tasks to yield to the browser
When the goal is responsiveness, a task boundary can be useful because it gives the browser a chance to render and handle input.
function processLargeList(items) {
let index = 0;
function step() {
const end = Math.min(index + 100, items.length);
while (index < end) {
// process items[index]
index++;
}
if (index < items.length) {
setTimeout(step, 0);
}
}
step();
}This approach avoids locking up the main thread for too long.
Keep queue choice consistent within one workflow
Mixing tasks and microtasks inside a small flow can make ordering hard to reason about. Pick one queue purpose and stick to it.
Promise.resolve()
.then(() => updateState())
.then(() => renderPreview());This is clearer than scattering related steps across unrelated timers, because the chain documents the order directly.
8. Limitations and Edge Cases
- Tasks are not all identical. Different browser APIs can enqueue different kinds of tasks, even though they still wait behind microtasks.
- Microtasks can delay rendering if you schedule too many of them in a row.
- Timer delay values are not guarantees. A 0 ms timer can still run later than expected because the current call stack and queued microtasks must finish first.
- Node.js has similar concepts, but its event loop phases differ from the browser model in detail.
- Promise callbacks from rejected or fulfilled promises still enter the microtask queue, even if they look synchronous in code.
A common "not working" complaint is that a UI change does not appear until after a long batch of microtasks. That usually means the browser never got a chance to render between queued microtasks and the next task.
9. Practical Mini Project
Here is a small browser example that shows how a status update, a promise-based follow-up, and a timer interact. The code logs the order and updates the page so you can observe the behavior directly.
<button id="run">Run demo</button>
<p id="output"></p>
<script>
const output = document.getElementById("output");
const runButton = document.getElementById("run");
function log(message) {
output.textContent += message + "\n";
}
runButton.addEventListener("click", () => {
output.textContent = "";
log("1. click handler start");
Promise.resolve().then(() => log("2. microtask"));
setTimeout(() => log("3. task"), 0);
log("4. click handler end");
});
</script>When you click the button, the click handler runs first because it is the current task. Then the promise callback runs as a microtask, and the timer callback runs later as the next task. This example is small, but it mirrors the exact ordering questions developers face in real UI code.
10. Key Points
- The call stack runs synchronous code first.
- Microtasks run before the event loop takes the next task.
- Promise callbacks and queueMicrotask() callbacks go to the microtask queue.
- setTimeout() callbacks go to the task queue.
- Too many microtasks can delay rendering and other events.
11. Practice Exercise
- Predict the output order of a small snippet that mixes console.log(), Promise.resolve().then(), queueMicrotask(), and setTimeout().
- Then verify your answer by running the code in a browser console.
- Try changing one line at a time to see how the order changes.
Expected output: synchronous logs first, then all microtasks in the order they were queued, then timer callbacks.
Hint: Think in three layers: current stack, microtasks, then tasks.
console.log("A");
queueMicrotask(() => console.log("B"));
Promise.resolve().then(() => console.log("C"));
setTimeout(() => console.log("D"), 0);
console.log("E");Solution:
// Output order:
// A
// E
// B
// C
// D12. Final Summary
The JavaScript event loop is the system that keeps asynchronous code moving without blocking synchronous execution. It lets JavaScript finish the current stack, then process microtasks, and only then move on to the next task.
That ordering is the key to understanding why promise callbacks often run before timers, why queueMicrotask() is useful for immediate follow-up work, and why too many microtasks can delay rendering. Once you can reason about the stack, microtasks, and tasks together, asynchronous JavaScript becomes much easier to debug and design.
If you want to go further, study browser rendering timing, promise chaining, and how Node.js event loop phases differ from the browser model.