JavaScript Event Loop Phases: Macrotasks vs Microtasks
JavaScript is single-threaded for your code, but it still handles timers, promises, DOM events, and network callbacks in a very specific order. This article explains how the event loop phases work, how macrotasks and microtasks are scheduled, and why some callbacks run sooner than others.
Quick answer: Macrotasks are larger units of work like setTimeout callbacks and UI events, while microtasks are higher-priority jobs like promise reactions and queueMicrotask callbacks. After the current call stack finishes, JavaScript drains all microtasks before moving to the next macrotask.
Difficulty: Beginner
You'll understand this better if you know: basic functions, the idea of synchronous vs asynchronous code, and how promises or timers work at a simple level.
1. What Is the Event Loop?
The event loop is the scheduling system that lets JavaScript handle asynchronous work without running your code in parallel. It continuously checks whether the current call stack is empty, then pulls the next job from the appropriate queue and executes it.
- It coordinates synchronous code, timers, promise callbacks, and browser events.
- It helps JavaScript stay responsive even when many callbacks are waiting.
- It does not make JavaScript multi-threaded; it only controls when callbacks run.
When people say “the event loop,” they usually mean the overall mechanism that decides what runs next. The important practical split is between macrotasks and microtasks.
2. Why Macrotasks and Microtasks Matter
Understanding task order helps you predict logs, avoid race conditions, and write code that updates state at the right time. Many confusing bugs come from assuming that a timer, a promise, or a DOM callback will run immediately after the current line.
For example, a promise callback usually runs before setTimeout(..., 0), even though both are asynchronous. That difference matters when you depend on execution order.
- It explains why promise handlers often run before timers.
- It helps you avoid unexpected UI updates and stale state.
- It makes debugging async code much easier.
3. Basic Syntax or Core Idea
At a high level, JavaScript processes work in this order:
- Run the current synchronous code on the call stack.
- Drain the microtask queue completely.
- Pick the next macrotask and run it.
- After that macrotask finishes, drain microtasks again.
Here is the smallest example that shows the difference:
console.log("start");
setTimeout(() => {
console.log("timeout");
}, 0);
Promise.resolve().then(() => {
console.log("promise");
});
console.log("end");This prints start, end, promise, and then timeout. The timer callback is a macrotask, while the promise handler is a microtask.
4. Step-by-Step Examples
Example 1: Synchronous code always runs first
Before any queue is considered, JavaScript finishes the current script execution. That means plain function calls happen before promise handlers or timers.
console.log("A");
console.log("B");
Promise.resolve().then(() => console.log("C"));
console.log("D");This prints A, B, D, then C. The promise callback waits until the synchronous script completes.
Example 2: Microtasks run before timers
This example shows the most common ordering surprise. Even with a zero-delay timer, the promise callback still runs first.
setTimeout(() => {
console.log("timer");
}, 0);
Promise.resolve().then(() => {
console.log("microtask");
});The output is microtask and then timer. Timers are macrotasks and must wait until the microtask queue is empty.
Example 3: queueMicrotask behaves like a promise reaction
queueMicrotask lets you schedule a microtask directly. It is useful when you want a callback to run after the current stack but before timers or rendering work that happens later.
console.log("1");
queueMicrotask(() => {
console.log("2");
});
console.log("3");This prints 1, 3, then 2. A microtask is still deferred, but it runs before the next macrotask.
Example 4: Nested scheduling changes the order
When a callback schedules more work, that new work is added to the correct queue. This can create layered ordering that looks odd until you remember the queue rules.
setTimeout(() => {
console.log("timeout 1");
Promise.resolve().then(() => {
console.log("microtask inside timeout");
});
}, 0);
setTimeout(() => {
console.log("timeout 2");
}, 0);After the first timer finishes, its promise callback is placed in the microtask queue and runs before the second timer. That is why microtask inside timeout appears between the two timer logs.
5. Practical Use Cases
- Updating state after a synchronous function finishes, but before the browser handles the next task.
- Making promise chains predictable when several async steps depend on each other.
- Scheduling lightweight follow-up work with queueMicrotask instead of waiting for a timer.
- Separating user-input handling from deferred work so the UI stays responsive.
- Debugging why a callback fired earlier or later than expected.
6. Common Mistakes
Mistake 1: Assuming setTimeout(0) runs immediately
A zero-delay timer does not mean “run next line.” It means “run in a future macrotask when the event loop gets there.”
Problem: The timer callback is scheduled after the current task and after any pending microtasks, so it will not run before promise handlers.
console.log("before");
setTimeout(() => console.log("timer"), 0);
console.log("after");Fix: If you need work to happen after the current stack but before the next macrotask, use a microtask.
console.log("before");
queueMicrotask(() => console.log("microtask"));
console.log("after");The corrected version works because microtasks are drained before the event loop moves to the next macrotask.
Mistake 2: Creating an endless microtask chain
Microtasks are processed until the queue is empty. If each microtask schedules another microtask, the browser or runtime can stay busy and delay rendering or timers.
Problem: This code keeps adding microtasks and can starve the event loop, so other work does not get a chance to run.
function spin() {
queueMicrotask(spin);
}
spin();Fix: Break long work into macrotasks or add a limit so the browser can breathe between chunks.
let count = 0;
function workChunk() {
if (count >= 100) return;
count++;
setTimeout(workChunk, 0);
}
workChunk();The fixed version yields control between chunks, which keeps the event loop responsive.
Mistake 3: Confusing promise callbacks with regular synchronous code
Promise reactions are asynchronous, even when the promise is already resolved. This trips up developers who expect the resolved value to be available immediately.
Problem: The variable is still undefined when it is logged, because the then callback has not run yet.
let value;
Promise.resolve(42).then(result => {
value = result;
});
console.log(value);Fix: Read the value inside the microtask chain, or use async and await so the timing is explicit.
async function showValue() {
const value = await Promise.resolve(42);
console.log(value);
}
showValue();The corrected version works because the log happens after the microtask that resolves the promise has completed.
7. Best Practices
Practice 1: Use microtasks for short follow-up work
If the follow-up work is tiny and must happen before the next macrotask, queueMicrotask is a good fit. It keeps the ordering explicit without pretending the work is synchronous.
function updateCache(value) {
queueMicrotask(() => {
console.log("cache updated for", value);
});
}This keeps the main call readable and reserves the microtask for a quick follow-up action.
Practice 2: Use macrotasks to yield during heavy work
For long loops or chunked processing, macrotasks are often better because they let the browser handle input and rendering between chunks.
const items = [1, 2, 3, 4, 5];
let index = 0;
function processNext() {
if (index >= items.length) return;
console.log(items[index++]);
setTimeout(processNext, 0);
}
processNext();This approach prevents one large job from blocking the event loop for too long.
Practice 3: Keep queue-sensitive logic simple and predictable
When execution order matters, reduce the number of nested callbacks. Clearer scheduling is easier to debug than deeply interleaved microtasks and timers.
function loadThenRender() {
return Promise.resolve()
.then(() => "data")
.then(data => {
console.log(data);
});
}Simple chains are easier to reason about than mixing unrelated timers and promise handlers in the same flow.
8. Limitations and Edge Cases
- Microtasks can starve rendering if they keep rescheduling themselves.
- Timers are not guaranteed to run exactly at the requested delay; 0 means “as soon as possible,” not “immediately.”
- In browsers, DOM events and rendering are tied to the event loop, but rendering does not happen in the middle of your synchronous code.
- Different environments expose slightly different task sources. Browser event loops and Node.js event loops both have microtasks, but their internal phases are not identical.
- Promise callbacks and queueMicrotask callbacks share the same general microtask priority, so both run before the next macrotask.
9. Practical Mini Project
Let’s build a tiny logger that records the order in which different kinds of work run. This is useful for learning and for debugging async ordering in real projects.
const log = [];
function record(label) {
log.push(label);
}
record("sync 1");
queueMicrotask(() => record("microtask"));
setTimeout(() => record("macrotask"), 0);
record("sync 2");
setTimeout(() => {
console.log(log);
}, 10);This example collects the order into an array and prints it later. A typical result is ["sync 1", "sync 2", "microtask", "macrotask"]. It shows that synchronous work finishes first, then the microtask, then the timer callback.
10. Key Points
- The event loop decides when asynchronous callbacks run.
- Synchronous code always finishes before queued tasks are processed.
- Microtasks run before the next macrotask.
- Promise reactions and queueMicrotask callbacks are microtasks.
- setTimeout and similar callbacks are macrotasks.
- Too many microtasks can delay timers and rendering.
11. Practice Exercise
- Predict the output of a script that uses one synchronous log, one promise callback, one queueMicrotask, and one setTimeout.
- Then run it in your browser console or Node.js and compare the result.
- Change the order of the statements and see which parts stay the same.
Expected output: The synchronous logs appear first, then the microtasks in the order they were scheduled, and finally the timer.
Hint: Remember that all microtasks drain before the event loop moves to the next macrotask.
console.log("sync");
Promise.resolve().then(() => console.log("promise"));
queueMicrotask(() => console.log("microtask"));
setTimeout(() => console.log("timeout"), 0);12. Final Summary
The event loop is the system that decides when JavaScript runs queued work. The key idea is simple: synchronous code runs first, then the microtask queue is drained, and only after that does the next macrotask run. This is why promise handlers and queueMicrotask callbacks usually happen before timers.
Once you internalize the difference between macrotasks and microtasks, async ordering becomes much easier to reason about. You will be better at debugging unexpected output, designing responsive UI code, and choosing the right scheduling tool for each job.
If you want to go further, next study the browser render cycle and how promises, timers, and input events interact with it in real applications.