The event loop becomes easier when you stop treating it like magic and start treating it like a scheduling rule.
JavaScript runs one piece of synchronous code at a time. Async APIs do not make your code run in parallel on the same thread. They schedule callbacks to run later.
Quick answer
When JavaScript finishes the current synchronous code, it runs queued promise callbacks before timer callbacks.
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");
The output is:
A
D
C
B
That one example explains a lot of UI timing bugs, backend queue callbacks, and “why did this run first?” moments.
The four pieces
A useful mental model has four parts:
- The call stack.
- Web APIs or runtime APIs.
- The task queue.
- The microtask queue.
The call stack is where synchronous JavaScript runs.
Runtime APIs handle things like timers, network events, file system callbacks, and browser events.
The task queue receives callbacks like setTimeout.
The microtask queue receives callbacks from promises and queueMicrotask.
The key rule
After the current synchronous code finishes, JavaScript drains the microtask queue before it takes the next task.
That rule explains a lot.
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");
The output is:
A
D
C
B
Synchronous logs run first. The promise callback runs as a microtask. The timer callback runs later as a task.
Why setTimeout 0 is not immediate
setTimeout(fn, 0) does not mean “run now.” It means “schedule this callback as a future task as soon as the runtime can.”
The current call stack must finish first. Then microtasks must run. Only then can the timer callback run.
That is why a promise can beat a zero-delay timer.
Microtasks can starve tasks
Because microtasks are drained before the next task, an endless chain of microtasks can delay timers and UI events.
function loop() {
queueMicrotask(loop);
}
loop();
setTimeout(() => {
console.log("timer");
}, 0);
The timer may never get a turn because the microtask queue keeps refilling itself.
This is rare in normal code, but it matters when building schedulers, frameworks, or heavy async flows.
async and await use promises
await pauses the async function and resumes it later through the promise/microtask machinery.
async function run() {
console.log("1");
await Promise.resolve();
console.log("2");
}
run();
console.log("3");
Output:
1
3
2
The function starts synchronously. After await, the continuation is scheduled later.
Node.js has extra queues
Node has its own details, including process.nextTick, timers, I/O callbacks, check phase, and setImmediate.
For most application debugging, start with this simpler rule:
- Synchronous code first.
- Promise microtasks next.
- Timer and I/O tasks later.
Then learn Node-specific phases when you are debugging server behavior or performance.
A practical debugging checklist
When async ordering surprises you, ask:
- Did this line run synchronously?
- Did this callback come from a promise?
- Did this callback come from a timer, event, or I/O operation?
- Is a microtask chain delaying tasks?
- Am I assuming
awaitblocks the whole program instead of only this function?
That checklist solves most event-loop confusion.
The mental model
Think of JavaScript as a single worker with two inboxes.
The microtask inbox gets priority and is emptied completely after each synchronous turn. The task inbox waits for the next turn.
Once that clicks, promise ordering, await, timer behavior, and many UI update bugs become much easier to reason about.
Discussion
What would you try, change, or challenge after reading this guide? Specific results and errors help the next reader.
Comments will load as you reach this section.