Event Loop & Concurrency Get Certified
JavaScript is single-threaded, yet it can handle many asynchronous operations without blocking the main thread. The secret is the event loop — the mechanism that coordinates the call stack, Web APIs, microtask queue, and macrotask queue.
Understanding the event loop is essential for writing non-blocking, performant code and for predicting the order in which asynchronous operations actually run.
The Call Stack
The call stack is where JavaScript keeps track of function calls. Synchronous code runs to completion before anything else can happen.
What this code does: Demonstrates how the call stack works with nested function calls. Each function is pushed onto the stack and popped off when it finishes.
function first() {
console.log('First');
second();
}
function second() {
console.log('Second');
third();
}
function third() {
console.log('Third');
}
console.log('Start');
first();
console.log('End');
first logs “First” then calls second(), pushing another frame onto the call stack.second logs “Second” then calls third().third logs “Third” and returns, popping its frame off the stack.console.log('Start'); runs first on the call stack.first(); the stack grows: first → second → third, then unwinds as each function returns.console.log('End'); runs only after the entire call chain has finished. Output order: Start → First → Second → Third → End.setTimeout and the Macrotask Queue
setTimeout schedules a callback into the
macrotask queue. Even with a delay of 0, the callback still
waits until the call stack is empty and all microtasks have
finished.
What this code does: Shows that setTimeout(..., 0) still
runs after all synchronous code.
console.log('1: Sync start');
setTimeout(function () {
console.log('3: setTimeout callback');
}, 0);
console.log('2: Sync end');
console.log('1: Sync start'); runs immediately on the call stack.setTimeout(..., 0) hands the callback to the Web API / timer; it is queued as a macrotask and does not run yet.console.log('2: Sync end'); still runs before any timer callback. Output: 1 → 2 → 3.Promises and the Microtask Queue
Promise callbacks (and queueMicrotask) go into
the microtask queue, which has higher priority than the
macrotask queue. All microtasks are drained before the next
macrotask runs.
What this code does: Demonstrates that Promise microtasks run
before setTimeout macrotasks.
console.log('1: Sync');
setTimeout(function () {
console.log('4: Macrotask (setTimeout)');
}, 0);
Promise.resolve().then(function () {
console.log('3: Microtask (Promise)');
});
console.log('2: Sync end');
console.log('1: Sync');setTimeout(..., 0) queues a macrotask for later.Promise.resolve().then(...) queues a microtask. Microtasks always run before the next macrotask.console.log('2: Sync end'); Output order: 1 → 2 → 3 (microtask) → 4 (macrotask).Complete Execution Order
What this code does: Combines timers, Promises and nested callbacks into one comprehensive demonstration of the complete event-loop ordering rules.
// Complete execution order demonstration
console.log('1: Sync code start');
setTimeout(function() {
console.log('6: Macro task (setTimeout)');
}, 0);
Promise.resolve()
.then(function() {
console.log('3: Micro task 1 (Promise)');
setTimeout(function() {
console.log('7: Macro task inside micro task');
}, 0);
})
.then(function() {
console.log('4: Micro task 2 (Promise)');
});
queueMicrotask(function() {
console.log('5: Micro task 3 (queueMicrotask)');
});
console.log('2: Sync code end');
// Execution order:
// 1. All synchronous code
// 2. All microtasks (Promises, queueMicrotask)
// 3. One macrotask (setTimeout, setInterval)
// 4. Repeat: microtasks → macrotask → microtasks → macrotask...
// Complete execution order demonstrationconsole.log('1: Sync code start');setTimeout queues log 6 as a macrotask.Promise.resolve().then(...) queues microtask 3; inside it another setTimeout schedules macrotask 7. The second .then queues microtask 4.queueMicrotask queues microtask 5 directly.console.log('2: Sync code end');Event Loop Phases
| Phase | Description | Examples |
|---|---|---|
| Synchronous Execution | Execute all synchronous code | Function calls, calculations |
| Microtask Queue | Process all microtasks | Promises, queueMicrotask |
| Render (Browser) | Update UI if needed | Repaints, reflows |
| Macrotask Queue | Process one macrotask | setTimeout, setInterval, I/O |
| Check Microtasks | Process microtasks again | Promises from macrotask |
| Repeat | Continue loop | Until queues are empty |
Concurrency Patterns
JavaScript handles concurrency through asynchronous patterns rather than true parallelism.
What this code does:
Starts several independent Promises concurrently and waits for all
of them with Promise.all.
// Concurrent operations with Promises
function task1() {
return new Promise(function(resolve) {
setTimeout(function() {
console.log('Task 1 complete');
resolve('Result 1');
}, 100);
});
}
function task2() {
return new Promise(function(resolve) {
setTimeout(function() {
console.log('Task 2 complete');
resolve('Result 2');
}, 50);
});
}
function task3() {
return new Promise(function(resolve) {
setTimeout(function() {
console.log('Task 3 complete');
resolve('Result 3');
}, 75);
});
}
console.log('Starting concurrent tasks');
// Run all tasks concurrently
Promise.all([task1(), task2(), task3()])
.then(function(results) {
console.log('All tasks complete');
console.log('Results:', results);
});
// Tasks run concurrently, not sequentially
// Concurrent operations with Promisestask1 returns a Promise that resolves after 100 ms with “Result 1”.task2 resolves after 50 ms with “Result 2”.task3 resolves after 75 ms with “Result 3”.console.log('Starting concurrent tasks');Promise.all([task1(), task2(), task3()]) starts all three timers at once and waits until every one has resolved; then it logs the combined results array.Promise.all only continues after the slowest one is done.Promise.race() Pattern
Promise.race() resolves or rejects as soon as one of the promises settles.
What this code does:
Uses Promise.race so the first Promise to settle wins,
demonstrating a common concurrency pattern.
// Promise.race demonstration
function timeout(ms) {
return new Promise(function(resolve) {
setTimeout(function() {
resolve('Timeout after ' + ms + 'ms');
}, ms);
});
}
console.log('Racing three promises');
Promise.race([
timeout(100),
timeout(200),
timeout(50)
])
.then(function(result) {
console.log('First to complete:', result);
});
// Output: First to complete: Timeout after 50ms
// Useful for implementing timeouts
// Promise.race demonstrationtimeout(ms) returns a Promise that resolves after the given number of milliseconds with a descriptive string.console.log('Racing three promises');Promise.race([timeout(100), timeout(200), timeout(50)]) settles as soon as the first Promise resolves or rejects — here the 50 ms timer wins..then(...) logs “First to complete: Timeout after 50ms”.Debouncing
Debouncing delays function execution until after a specified time has elapsed since the last call.
What this code does: Implements a classic debounce helper that delays execution until calls have stopped for a given interval.
// Debounce function
function debounce(func, delay) {
let timeoutId;
return function() {
const args = arguments;
clearTimeout(timeoutId);
timeoutId = setTimeout(function() {
func.apply(null, args);
}, delay);
};
}
// Usage example
const expensiveOperation = debounce(function(value) {
console.log('Processing:', value);
}, 300);
// Simulate rapid calls (like typing)
console.log('Simulating rapid calls:');
expensiveOperation('a');
expensiveOperation('ab');
expensiveOperation('abc');
expensiveOperation('abcd');
// Only the last call executes after 300ms
// Useful for search input, window resize, etc.
// Debounce functionfunction debounce(func, delay) {let timeoutId; closed over by the returned function.return function() {const args = arguments;clearTimeout(timeoutId); so only the latest call can fire.timeoutId = setTimeout(...) calls the original function after delay ms of quiet.};}expensiveOperation will only run 300 ms after the last call.console.log('Simulating rapid calls:');Throttling
Throttling ensures a function executes at most once per specified time interval.
What this code does: Implements a throttle helper that limits how often a function may run, regardless of how frequently it is invoked.
// Throttle function
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
if (!inThrottle) {
func.apply(null, args);
inThrottle = true;
setTimeout(function() {
inThrottle = false;
}, limit);
}
};
}
// Usage example
let callCount = 0;
const throttledFunction = throttle(function() {
callCount++;
console.log('Throttled call #' + callCount);
}, 200);
// Simulate rapid calls
console.log('Simulating rapid calls:');
for (let i = 0; i < 10; i++) {
setTimeout(function() {
throttledFunction();
}, i * 50);
}
// Function executes at most once per 200ms
// Useful for scroll events, API rate limiting
// Throttle functionfunction throttle(func, limit) {let inThrottle;return function() {const args = arguments;if (!inThrottle) {func.apply(null, args);inThrottle = true;setTimeout(...) clears the flag after limit ms so the next call can run.}};}let callCount = 0;console.log('Simulating rapid calls:');Implementing an Async Queue
An async queue processes tasks sequentially, even if they're added asynchronously.
What this code does: Builds a simple async queue that processes tasks one after another so concurrent work is serialised safely.
// Async queue implementation
function AsyncQueue() {
this.queue = [];
this.processing = false;
}
AsyncQueue.prototype.add = function(task) {
this.queue.push(task);
this.process();
};
AsyncQueue.prototype.process = function() {
if (this.processing || this.queue.length === 0) {
return;
}
this.processing = true;
const task = this.queue.shift();
const self = this;
task()
.then(function() {
self.processing = false;
self.process();
})
.catch(function(error) {
console.error('Task failed:', error);
self.processing = false;
self.process();
});
};
// Usage
const queue = new AsyncQueue();
queue.add(function() {
return new Promise(function(resolve) {
setTimeout(function() {
console.log('Task 1 complete');
resolve();
}, 100);
});
});
queue.add(function() {
return new Promise(function(resolve) {
setTimeout(function() {
console.log('Task 2 complete');
resolve();
}, 50);
});
});
queue.add(function() {
return new Promise(function(resolve) {
setTimeout(function() {
console.log('Task 3 complete');
resolve();
}, 75);
});
});
console.log('Tasks added to queue');
// Async queue implementationAsyncQueue keeps an array of pending tasks and a processing flag so only one task runs at a time.add(task) pushes a new task onto the queue and immediately tries to process.if (this.processing || this.queue.length === 0) return; does nothing if already busy or empty.this.processing = true; and shift() takes the next task.const self = this; so callbacks can reach the queue instance.process() is called again to drain the next item — serialising concurrent work.};const queue = new AsyncQueue();console.log('Tasks added to queue'); Tasks finish in the order they were added (1 → 2 → 3), not by duration.Best Practices
- Avoid blocking the event loop with long-running synchronous operations
- Break up heavy computations into smaller chunks
- Use async/await for cleaner asynchronous code
- Understand the difference between microtasks and macrotasks
- Use Promise.all() for concurrent operations
- Implement debouncing/throttling for frequent events
- Avoid creating too many nested promises
- Always handle promise rejections
- Use appropriate data structures for task queues
- Monitor and profile async operations in production
Common Mistakes
- Blocking the event loop with synchronous operations
- Misunderstanding execution order of async code
- Creating race conditions by not sequencing async operations
- Using setTimeout(fn, 0) expecting immediate execution
- Not handling promise rejections (unhandled rejection warnings)
- Creating infinite loops with promises or async/await
- Forgetting that async functions always return promises
- Mixing callbacks and promises inconsistently
- Not considering microtask vs macrotask priority
- Over-using async/await when not necessary
Quick Summary
- JavaScript is single-threaded but handles concurrency through the event loop
- The call stack executes synchronous code
- Web APIs handle asynchronous operations
- The microtask queue has higher priority than the task queue
- Promises use the microtask queue
- setTimeout/setInterval use the task queue (macrotask)
- Execution order: Sync → Microtasks → One Macrotask → Repeat
- Async/await is syntactic sugar over promises
- Avoid blocking the event loop with long operations
- Use debouncing/throttling for performance optimization
- Handle concurrency properly to avoid race conditions
Try It Yourself
Practice predicting execution order. Try to guess the output before running the code.
What this code does: A practice challenge: predict the console output order before running the mixed sync / microtask / macrotask code.
// Practice: Predict the output order
console.log('A');
setTimeout(function() {
console.log('B');
Promise.resolve().then(function() {
console.log('C');
});
}, 0);
Promise.resolve()
.then(function() {
console.log('D');
setTimeout(function() {
console.log('E');
}, 0);
})
.then(function() {
console.log('F');
});
setTimeout(function() {
console.log('G');
}, 0);
console.log('H');
// Try to predict the order before running!
// Answer: A → H → D → F → B → C → G → E
// Practice: Predict the output orderconsole.log('A');setTimeout queues macrotask B; when B later runs it also queues microtask C.Promise.resolve().then queues microtask D; inside D a nested setTimeout queues macrotask E; the second .then queues microtask F.setTimeout queues macrotask G.console.log('H');// Answer: A → H → D → F → B → C → G → E Sync first, then all microtasks, then macrotasks in order; C runs as a microtask after B’s macrotask starts.Tip: Understanding the event loop is crucial for writing performant JavaScript applications. Practice predicting execution order to master async programming and avoid common pitfalls like race conditions and blocking operations.
Mastering the event loop and concurrency model is essential for advanced JavaScript development. This knowledge helps you write efficient, non-blocking code and debug complex asynchronous issues.
🧪 Test Your JavaScript Code
Edit the code on the left and click "Run Code" to see the console output on the right.
Click "Run Code" to see the output here.
Code Explanation
The complete example stays visible. Use Previous and Next to move through its logical blocks.
Select Next to begin exploring the code.