JavaScript Event Loop: How It Really Works, Call Stack, Microtask and Macrotask
The event loop is the heart of JavaScript but most devs have no understanding of it vague. We explain precisely: call stack, web APIs, callback queue, microtask queue (Promise), macrotask queue (setTimeout) and why the execution order may surprise you.
JavaScript is Single-Threaded: What It Really Means
JavaScript has a single thread of execution: at any time, only one JavaScript statement is executing. This is a runtime fact, not a limit of language. The JavaScript Engine (V8 in Chrome/Node.js, SpiderMonkey in Firefox) runs the code sequentially.
Yet Node.js handles tens of thousands of concurrent requests. As? Via the
event loop: A mechanism that allows you to wait for asynchronous I/O operations
(network, filesystem) without blocking the thread, delegating the actual work to the operating system
via libuv.
The Components of the Event Loop
The event loop in Node.js (V8 + libuv) consists of:
- Call Stacks: The synchronous execution stack of JavaScript code. When a function is called, it is "pushed" onto the stack; when it finishes, it is "fed". If the stack is not empty, the thread is busy.
-
Web APIs / Node APIs: interfaces provided by the environment (browser or Node.js)
for asynchronous I/O operations:
setTimeout,fetch,fs.readFile, WebSockets, etc. These operations are delegated to the C++ runtime (libuv) and do NOT use the JS thread. - Callback Queue (Macrotask Queue): Queue of callbacks to execute next I/O operations completed, timeout, interval Executed AFTER the call stack is empty and the microtask queue is emptied.
-
Microtask Queue: High priority queue for Promise callbacks
(
.then(),async/await) AndqueueMicrotask(). He's coming completely emptied BEFORE each macrotask.
The Order of Execution: the Fundamental Rule
The most important rule of event loop:
- Run all synchronous code on the call stack
- Completely clear the microtask queue (Promise, MutationObserver)
- Run the next macrotask from the callback queue (setTimeout, I/O callback)
- Go back to point 2
This explains behavior that surprises many developers:
console.log('1 - sincrono');
setTimeout(() => console.log('4 - macrotask'), 0);
Promise.resolve()
.then(() => console.log('2 - microtask 1'))
.then(() => console.log('3 - microtask 2'));
console.log('1b - sincrono');
// Output:
// 1 - sincrono
// 1b - sincrono
// 2 - microtask 1
// 3 - microtask 2
// 4 - macrotask
// Perché? Il setTimeout con delay=0 è comunque un macrotask.
// Le Promise sono microtask, eseguite PRIMA del prossimo macrotask.
View the Event Loop Step by Step
// Esempio completo — traccia mentale dell'esecuzione
async function main() {
console.log('A'); // [1] push main, push log('A')
setTimeout(() => console.log('B'), 0); // [2] delega a Web APIs
await Promise.resolve('resolved'); // [3] sospende main, schedula microtask
console.log('C'); // [6] riprende dopo microtask
}
console.log('D'); // [4] esegue sincrono
main(); // [1] chiama main
console.log('E'); // [5] esegue sincrono dopo main si sospende
// Esecuzione:
// [1] D (sincrono prima di main)
// [2] A (main inizia, prima console.log)
// [3] main si sospende su await
// [4] E (codice sincrono dopo main())
// [5] Stack vuoto, microtask queue: Promise.resolve callback
// [6] C (main riprende dopo await)
// [7] Stack vuoto, macrotask queue: setTimeout callback
// [8] B
// Output finale: D, A, E, C, B
Promise and async/await: Under the Hood
async/await it's syntactic sugar on top of the Promises. The compiler transforms
every await in a .then(). Understanding the transformation helps to understand
the order of execution:
// Questa funzione async:
async function processUser(id) {
const user = await fetchUser(id); // await punto 1
const orders = await fetchOrders(user); // await punto 2
return { user, orders };
}
// È equivalente a:
function processUserWithPromises(id) {
return fetchUser(id)
.then(user => {
return fetchOrders(user)
.then(orders => { return { user, orders }; });
});
}
// La funzione si "sospende" a ogni await e
// riprende quando la Promise risolve (tramite microtask queue)
Microtask Starvation: An Anti-Pattern to Avoid
If the microtask queue is never emptied, the macrotasks (and therefore the I/O callbacks) are not never performed. This is called microtask starvation:
// ANTI-PATTERN: loop infinito di microtask — blocca tutto!
function recursiveMicrotask() {
Promise.resolve().then(recursiveMicrotask); // schedula infiniti microtask
}
recursiveMicrotask();
// setTimeout di seguito non verrà MAI eseguito!
// PATTERN CORRETTO: usa setImmediate (Node.js) o setTimeout per cedere il controllo
function processLargeDataset(data, index = 0) {
if (index >= data.length) return;
processItem(data[index]);
// Cede il controllo all'event loop ogni 100 elementi
if (index % 100 === 0) {
setImmediate(() => processLargeDataset(data, index + 1));
} else {
processLargeDataset(data, index + 1);
}
}
Node.js Event Loop: The Phases
The Node.js event loop has specific stages (libuv) that go beyond the browser:
// Le fasi del Node.js event loop (semplificato)
// 1. timers: esegue callback di setTimeout e setInterval
// 2. I/O callbacks: callback I/O differite (errori socket)
// 3. idle, prepare: uso interno Node.js
// 4. poll: recupera I/O events, esegue callback I/O
// 5. check: esegue callback di setImmediate()
// 6. close callbacks: es. socket.on('close', callback)
// setImmediate vs setTimeout(fn, 0): NON garantiti nell'ordine
// dipende dal momento di chiamata nel ciclo
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Output non deterministico se eseguiti nel main module
// Ma dentro una callback I/O:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Qui 'immediate' è sempre PRIMA — siamo già nella fase poll
});
Blocking the Event Loop: Classic Mistakes
Operations that Block the Event Loop
- Synchronous CPU-intensive computing: a loop that calculates for 2 seconds blocks all other requests for 2 seconds. Use Worker Threads for CPU-bound work.
- JSON.parse() on huge payloads: parsing a 10MB JSON is synchronous and blocks. Use JSON streams or delegate to Worker Thread.
- fs.readFileSync() in server code: Sync versions of all Node.js APIs they block the thread. Always use async versions with callbacks or await.
-
Crypto operations without flags: Cryptographic operations are CPU-bound.
crypto.scrypt()accept callbacks;crypto.scryptSync()block.
Conclusions
The event loop is what makes JavaScript efficient despite being single-threaded.
The keys to remember: the microtask queue has priority over the macrotask queue; await
does not block the thread but suspends the current function; CPU-bound synchronous operations blocks
the entire event loop.
In the next article we will explore the goroutines and Go channels: a model completely different concurrency platform, based on CSP and designed to scale to millions of tasks competitors.







