JavaScript Advanced #4 Event Loop and Tasks
In Intermediate #2 you saw how to use Promise and async/await. This post looks at how that async actually works — the event loop and task queues.
JavaScript is single-threaded #
First the most important fact — JavaScript executes one line at a time. While one function runs, another can never cut in.
function a() {
b();
console.log('a end');
}
function b() {
console.log('b end');
}
a();
// b end
// a end
The data structure that makes this work is the call stack. Functions push when called and pop when they return.
The next async runs only when the call stack is empty #
The key rule for how JavaScript handles async:
The next async task runs only when the call stack is empty.
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// 1, 3, 2 (this order)
Even with setTimeout(..., 0), 2 is last. The reason: all sync code on the call stack must finish first before the timer callback runs — even with a delay of 0ms.
The event loop #
What manages this is the event loop. Simplified, it does the following endlessly.
while (true) {
if (call stack empty) {
if (microtask queue not empty) {
// run all microtasks until the queue is empty
run all microtasks;
} else if (macrotask queue not empty) {
take one macrotask and run;
}
}
}Two key points.
- The queue is checked only when the call stack is empty — sync code first
- Microtasks run before macrotasks
Microtask vs macrotask #
JavaScript async splits into two queues.
| Kind | Where they come from |
|---|---|
| Macrotask (Task) | setTimeout, setInterval, I/O, UI events |
| Microtask | Promise.then/.catch/.finally, queueMicrotask, the part after await |
Microtasks run before macrotasks. And until the microtask queue is empty — running every available microtask.
Key example #
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// output order: 1, 4, 3, 2
Explanation:
console.log('1')— sync runsetTimeout— registered to macrotask queuePromise.resolve().then(...)— registered to microtask queueconsole.log('4')— sync run- Call stack empty → check microtask queue → output
3 - Microtask queue empty → check macrotask queue → output
2
That’s why a 0ms setTimeout runs later than Promise.then.
The part after await is also a microtask
#
async function a() {
console.log('1');
await null;
console.log('3');
}
console.log('start');
a();
console.log('2');
// start, 1, 2, 3
Inside a(), when await null is hit — the function pauses there and the next line (console.log('3')) is registered as a microtask. Control returns to the caller, console.log('2') runs, and then the microtask runs.
That’s how async/await actually works.
queueMicrotask — make microtasks directly
#
console.log('1');
queueMicrotask(() => console.log('2'));
console.log('3');
// 1, 3, 2
Both browser and Node have this. Shorter and clearer than Promise.resolve().then(...) for creating microtasks. Sometimes used inside library implementations.
Same queue is FIFO #
Tasks within the same queue are processed in arrival order.
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
setTimeout(() => console.log('C'), 0);
// A, B, C
Promise.resolve().then(() => console.log('A'));
Promise.resolve().then(() => console.log('B'));
Promise.resolve().then(() => console.log('C'));
// A, B, C
One macrotask vs all microtasks #
To emphasize — for each macrotask, all microtasks until the queue is empty are processed before moving to the next macrotask.
setTimeout(() => {
console.log('macro 1');
Promise.resolve().then(() => console.log('micro 1'));
}, 0);
setTimeout(() => console.log('macro 2'), 0);
// macro 1, micro 1, macro 2
When macro 1 runs, micro 1 is queued. After macro 1 finishes and the call stack is empty — before going to the macrotask queue, the microtask queue drains first. So micro 1 prints before macro 2.
Things to watch in practice #
1) Endless microtasks → starve macrotasks #
Endlessly scheduling microtasks inside microtasks blocks macrotasks and UI updates.
function loop() {
Promise.resolve().then(loop);
}
loop();
// browser freezes — no other events, no rendering
Doing the same with setTimeout is a macrotask, so the UI gets a chance. Microtasks have higher priority and are therefore riskier in this regard.
2) DOM updates and microtasks #
DOM changes inside Promise.resolve().then(...) are applied before rendering. Multiple DOM changes within the same frame still render once.
button.addEventListener('click', () => {
el.textContent = '1';
Promise.resolve().then(() => {
el.textContent = '2'; // user only sees '2'
});
});Part of why libraries like React can use microtasks for batched updates.
3) Sync code between awaits
#
async function process() {
console.log('1');
await fetch('/api');
console.log('2'); // runs only after fetch finishes
}
process();
console.log('3'); // runs while process is paused at await
When process() hits await, it pauses. The caller (console.log('3')) gets a chance to run. Once fetch finishes, process’s next line is queued as a microtask.
requestAnimationFrame and requestIdleCallback
#
In the browser, beyond macrotasks there are two callbacks synchronized with rendering.
requestAnimationFrame — runs on the next frame
#
requestAnimationFrame(() => {
// runs just before the next frame is painted (typically 60fps = 16.7ms)
el.style.transform = `translateX(${x}px)`;
});Fits animations and grouping DOM changes. More accurately frame-aligned than setTimeout.
requestIdleCallback — runs when idle
#
requestIdleCallback(() => {
// runs when the main thread is idle — low-priority work
saveAnalytics();
});Fits non-urgent work (analytics save, background precompute). Doesn’t block user interaction.
Node’s event loop — slightly different #
Node has an event loop too, but with more granular phases.
| Phase | Handles |
|---|---|
| timers | setTimeout / setInterval callbacks |
| I/O callbacks | some system callbacks |
| poll | receive new I/O events |
| check | setImmediate callbacks |
| close | socket.on('close', ...) |
There are also Node-only tools like setImmediate and process.nextTick — process.nextTick runs even before microtasks. Rarely needed in everyday code.
Wrap-up #
What we covered:
- JavaScript is single-threaded; async runs only when the call stack is empty
- Event loop — sync until the stack is empty, then microtasks → macrotasks
- Microtasks — Promise.then, queueMicrotask, the part after await
- Macrotasks — setTimeout, I/O, UI events
- After one macrotask, drain microtasks until empty
- Endless microtasks block the UI
requestAnimationFrame/requestIdleCallbacksync with rendering- Node’s event loop has more granular phases
In the next post (#5 Memory Model and GC) we cover how JavaScript manages memory — how the garbage collector works and the patterns that cause leaks.