JavaScript Event Loop — Microtasks, Macrotasks, and Asynchronous Execution
In this tutorial, you will learn about JavaScript Event Loop. We cover key concepts, practical examples, and best practices to help you master this topic.
The event loop is the beating heart of JavaScript's concurrency model. Despite being single-threaded, JavaScript handles thousands of asynchronous operations through an elegant coordination of the call stack, task queues, and microtask queues. Understanding this model is essential for writing non-blocking code and avoiding subtle bugs.
DodaTech relies on this understanding for processing thousands of concurrent scan results, handling Websocket connections, and building responsive UIs.
What You'll Learn
- The call stack and stack frames
- Task queue (macrotasks) vs microtask queue
- How setTimeout, Promises, and async/await interact
- requestAnimationFrame and the render pipeline
- Common event loop pitfalls
- Measuring and debugging async execution
The Call Stack
function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function main() {
console.log(square(5)); // 25
}
main();
// Stack trace:
// 1. main() pushed
// 2. square(5) pushed
// 3. multiply(5, 5) pushed
// 4. multiply returns → popped
// 5. square returns → popped
// 6. main returns → popped
Task Queue vs Microtask Queue
console.log("1: synchronous");
setTimeout(() => console.log("2: macrotask"), 0);
Promise.resolve().then(() => console.log("3: microtask"));
console.log("4: synchronous");
// Output order:
// 1: synchronous
// 4: synchronous
// 3: microtask ← microtasks run BEFORE next macrotask
// 2: macrotask
// Why? The event loop processes:
// 1. One macrotask (from the main script)
// 2. ALL available microtasks
// 3. Render (if needed)
// 4. Next macrotask
Detailed Execution Order
setTimeout(() => console.log("timeout 1"), 0);
setTimeout(() => console.log("timeout 2"), 0);
Promise.resolve()
.then(() => {
console.log("promise 1");
// Schedule another microtask
return Promise.resolve();
})
.then(() => console.log("promise 2"));
queueMicrotask(() => console.log("microtask via queueMicrotask"));
// Output:
// promise 1
// microtask via queueMicrotask
// promise 2
// timeout 1
// timeout 2
// All microtasks (promise 1, microtask, promise 2) run
// before the next macrotask (timeout 1).
Macrotask Sources
// Sources of macrotasks:
// - setTimeout / setInterval
// - setImmediate (Node.js)
// - I/O callbacks (Node.js)
// - UI events (click, keydown, etc.)
// - Message events (postMessage, WebSocket messages)
// setTimeout with 0ms delay:
let start = Date.now();
setTimeout(() => {
console.log(`Ran after ${Date.now() - start}ms`);
}, 0);
console.log("This runs first");
Microtask Sources
// Sources of microtasks:
// - Promise.then / .catch / .finally
// - async/await (syntactic sugar over Promises)
// - queueMicrotask()
// - MutationObserver
// - process.nextTick (Node.js, runs before other microtasks)
// Warning: microtasks can starve the event loop!
function starveEventLoop() {
function loop() {
queueMicrotask(loop);
}
loop();
// This NEVER yields to macrotasks!
// setTimeout(() => console.log("never runs"), 0);
}
async/await and the Event Loop
async function demo() {
console.log("A: inside async function (synchronous start)");
await Promise.resolve();
console.log("B: after first await (microtask)");
await Promise.resolve();
console.log("C: after second await (microtask)");
}
console.log("1: before async call");
demo();
console.log("2: after async call");
// Output:
// 1: before async call
// A: inside async function
// 2: after async call
// B: after first await
// C: after second await
requestAnimationFrame
// rAF runs BEFORE the next paint/render
// but AFTER all microtasks in the current cycle
requestAnimationFrame(() => console.log("rAF 1"));
requestAnimationFrame(() => console.log("rAF 2"));
Promise.resolve().then(() => console.log("microtask"));
// Output:
// microtask
// rAF 1
// rAF 2
// Event loop in browser:
// 1. Macrotask (e.g., click handler)
// 2. All microtasks
// 3. requestAnimationFrame callbacks
// 4. Style calculation + Layout + Paint
// 5. Next macrotask
Common Pitfalls
Pitfall 1: forEach with async
// WRONG: forEach doesn't await
async function processItems(items) {
items.forEach(async (item) => {
await fetch(`/api/${item}`);
// This runs after function returns!
});
console.log("Done?"); // Runs before any fetch completes
}
// RIGHT: for...of with await
async function processItemsCorrect(items) {
for (const item of items) {
await fetch(`/api/${item}`);
}
console.log("Actually done");
}
// RIGHT: Promise.all for parallel
async function processItemsParallel(items) {
await Promise.all(items.map(item => fetch(`/api/${item}`)));
console.log("All done (parallel)");
}
Pitfall 2: Microtask Starvation
async function processStream(stream) {
for await (const chunk of stream) {
// Processing chunk...
await 0; // VERY BAD: creates infinite microtasks
}
}
// Better: use actual async I/O
async function processStreamBetter(stream) {
for await (const chunk of stream) {
await processChunk(chunk);
// Each iteration yields properly
}
}
Pitfall 3: Timing Assumptions
// Don't assume setTimeout(fn, 0) runs in 0ms
const start = Date.now();
setTimeout(() => {
console.log(`Delta: ${Date.now() - start}ms`); // Usually 1-10ms+
}, 0);
// Browser minimum: 4ms (clamped)
// Node.js minimum: 1ms (after clamping is applied)
Debugging the Event Loop
// Monitor event loop lag (Node.js)
function monitorLag() {
const start = Date.now();
setImmediate(() => {
const lag = Date.now() - start;
if (lag > 50) {
console.warn(`Event loop lag: ${lag}ms`);
}
});
}
// Using performance.now() for high-res timing
function measureExecution(fn) {
const start = performance.now();
fn();
return performance.now() - start;
}
// Event loop visualization helper
function logTiming(label, color = "") {
console.log(`${label} @ ${performance.now().toFixed(2)}ms`);
}
Practice Questions
Predict the output of nested setTimeout and Promise chains.
Write a function that yields control to the event loop without using setTimeout.
Implement a non-blocking version of Array.forEach that processes items in batches (chunks), yielding to the event loop between batches.
Write a debounce function that respects the microtask/macrotask distinction.
Measure event loop lag in the browser using requestAnimationFrame timestamps.
Challenge: Cooperative Multitasking Scheduler
Build a scheduler that runs multiple "tasks" (generators) cooperatively:
class Scheduler {
tasks = new Map();
add(name, genFn) { ... }
run() { ... }
}
const sched = new Scheduler();
sched.add("task1", function*() {
console.log("A1"); yield;
console.log("A2"); yield;
console.log("A3");
});
sched.add("task2", function*() {
console.log("B1"); yield;
console.log("B2"); yield;
});
sched.run();
// A1, B1, A2, B2, A3 (interleaved)
This is the same pattern DodaTech uses for managing concurrent scan operations while keeping the UI thread responsive.
Real-World Task: Progressive Data Loader
Write a class that loads data in chunks while keeping the UI responsive:
- Load next chunk via requestIdleCallback or chunked microtasks
- Yield to the event loop after each chunk
- Emit progress events
- Support cancellation
- Handle errors without blocking
This powers DodaTech's dashboard where large datasets are loaded incrementally without freezing the browser — essential for displaying millions of scan results across tens of thousands of assets.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro