JavaScript Event Loop: Deep Dive
The event loop is the fundamental execution model of JavaScript. It is the reason JavaScript can handle thousands of concurrent operations on a single thread. Understanding the event loop is essential for writing predictable async code.
Single-Threaded, Non-Blocking
JavaScript runs on a single thread — one call stack, one thing at a time. Yet it handles I/O, timers, user interactions, and network requests without blocking. This is possible because the runtime (browser or Node.js) delegates blocking operations to other system components and processes callbacks when results are ready.
console.log('Start');
setTimeout(() => {
console.log('Timeout');
---, 0);
Promise.resolve().then(() => {
console.log('Promise');
---);
console.log('End');
// Output:
// Start
// End
// Promise
// Timeout
Understanding why Promise logs before Timeout requires understanding the task queue hierarchy.
The Call Stack
The call stack is a LIFO data structure that tracks function execution:
function multiply(a, b) {
return a * b;
---
function square(n) {
return multiply(n, n);
---
function main() {
console.log(square(5)); // 25
---
main();Stack trace:
[main] ← pushed when main() is called
[square] ← pushed when main calls square
[multiply] ← pushed when square calls multiply
[multiply] → returns 25 ← popped
[square] → returns 25 ← popped
[main] → returns ← poppedIf a function takes too long (e.g., an infinite loop or heavy computation), the stack stays full, events are not processed, and the UI freezes. This is “blocking the event loop.”
Task Queues
When an asynchronous operation completes, its callback does not execute immediately. It is placed in a queue to be processed when the call stack is empty.
Macrotask Queue (Task Queue)
// These create macrotasks:
setTimeout(callback, delay);
setInterval(callback, delay);
setImmediate(callback); // Node.js only
I/O callbacks (network, file);
UI rendering events;The event loop picks one macrotask from the queue, executes it fully, then checks microtasks before picking the next macrotask.
Microtask Queue
// These create microtasks:
Promise.then(callback);
Promise.catch(callback);
Promise.finally(callback);
queueMicrotask(callback);
MutationObserver (browser);
process.nextTick (Node.js — technically its own queue);The microtask queue is drained after each macrotask. If a microtask queues another microtask, that new microtask runs before the next macrotask. This means microtasks can starve the macrotask queue if they keep queuing more microtasks.
Rendering Queue (Browser)
The browser renders UI between macrotasks. This is why excessive JavaScript blocks rendering and causes jank. requestAnimationFrame runs before the next render, not in the task queue.
Event Loop Algorithm
1. Execute the oldest macrotask from the macrotask queue
2. Drain the microtask queue (run all microtasks)
3. Perform UI rendering (browser only — if needed)
4. Check for new macrotasks
5. Repeat// Visual proof
console.log('1: Call stack'); // 1
setTimeout(() => {
console.log('2: Macrotask'); // 5
---, 0);
Promise.resolve().then(() => {
console.log('3: Microtask'); // 3
---);
queueMicrotask(() => {
console.log('4: Another microtask'); // 4
---);
console.log('5: Still call stack'); // 2
// Output: 1, 5, 3, 4, 2
requestAnimationFrame
// rAF fires before the next paint, NOT in the task queue
requestAnimationFrame(() => {
console.log('3: Before paint');
---);
setTimeout(() => {
console.log('2: Macrotask');
---, 0);
Promise.resolve().then(() => {
console.log('1: Microtask');
---);
console.log('0: Call stack');
// Output: 0, 1, 2, 3
// Wait — rAF runs after macrotask but before paint
// Actually: 0, 1, 3, 2 — no, rAF runs before the next macrotask
// Actually in the event loop: macrotask → microtasks → rAF → render
The correct ordering in a browser event loop iteration:
- Pick one macrotask from the queue
- Run all microtasks (flush the microtask queue)
- Run
requestAnimationFramecallbacks - Perform rendering (layout, paint)
- Check for next macrotask
So rAF callbacks run after microtasks are drained but before the browser renders the frame.
Common Misconceptions
setTimeout(fn, 0) Does Not Run Immediately
setTimeout(() => console.log('timeout'), 0);
// It runs after the current call stack and microtasks are clear.
// Minimum delay is clamped to 4ms after the 5th nested timeout.
Promise Constructor Runs Synchronously
console.log('start');
const promise = new Promise((resolve) => {
console.log('executor');
resolve('resolved');
---);
promise.then((value) => console.log(value));
console.log('end');
// start, executor, end, resolved
The Promise executor function runs synchronously when the Promise is created. Only the .then callback is deferred to the microtask queue.
Starvation and Blocking
// Blocking the event loop
while (true) {
// infinite loop — nothing else can execute
---
// Microtask starvation
function recursiveMicrotask() {
queueMicrotask(recursiveMicrotask);
---
recursiveMicrotask();
// The microtask queue never empties — macrotasks never run
Long-running synchronous operations block everything. Microtask queuing can starve macrotasks. Avoid both patterns by breaking heavy work into chunks:
function processLargeArray(array, chunkSize = 1000) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, array.length);
for (let i = index; i < end; i++) {
processItem(array[i]);
}
index = end;
if (index < array.length) {
setTimeout(processChunk, 0); // yield to event loop
}
}
processChunk();
---Node.js Event Loop Phases
Node.js extends the browser event loop with additional phases:
┌───────────────────────────┐
┌─>│ timers │ ← setTimeout, setInterval callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ ← I/O callbacks deferred to next iteration
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ ← internal use only
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ ← retrieve I/O events, execute I/O callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ ← setImmediate callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │ ← close events (socket.on('close'))
│ └───────────────────────────┘process.nextTick runs between each phase (it is its own queue, checked after every phase in Node.js).
Timers and Precision
JavaScript timers have minimum delay clamping:
// Nested timeout clamping
let start = Date.now();
let count = 0;
function timer() {
count++;
if (count === 10) {
console.log('10 nested timeouts took', Date.now() - start, 'ms');
return;
}
setTimeout(timer, 0);
---
timer();
// After 5th nested timeout, delay is clamped to minimum 4ms
requestIdleCallback
Run non-urgent work during idle periods:
function analyzePerformance() {
// Expensive analysis that shouldn't block UI
const data = collectMetrics();
const report = generateReport(data);
console.log('Performance report:', report);
---
requestIdleCallback(analyzePerformance, { timeout: 5000 });Microtask vs Macrotask Queue Ordering
// Queue a microtask
Promise.resolve().then(() => console.log('microtask 1'));
queueMicrotask(() => console.log('microtask 2'));
// Queue a macrotask
setTimeout(() => console.log('macrotask 1'), 0);
// Synchronous
console.log('sync');
// Output:
// sync
// microtask 1
// microtask 2
// macrotask 1
The microtask queue is emptied completely before the next macrotask is processed. This means microtasks have priority over macrotasks and can theoretically starve them.
Real-World Implementation Tips
Production Considerations
When moving from development to production, several factors become critical. Error handling should be comprehensive — every external call (database, API, file system) should have proper error checking, logging, and retry logic where appropriate. Performance monitoring through metrics and structured logging helps identify bottlenecks before they affect users.
Testing Strategy
A thorough testing approach combines multiple levels:
- Unit tests verify individual functions and methods in isolation
- Integration tests validate that components work together correctly
- Edge case tests cover boundary conditions, empty inputs, and error states
- Performance tests ensure the system meets latency and throughput requirements
Test data should be realistic but controlled. Mock external dependencies to make tests fast and deterministic. Aim for tests that are independent, repeatable, and fast enough to run on every commit.
Documentation
Good documentation is essential for maintainable code. Follow these principles:
- Document the “why” not just the “what” — explain design decisions
- Keep examples up to date with the code
- Include usage examples for public APIs
- Document configuration options and their defaults
- Explain error conditions and recovery strategies
Security Best Practices
Security should be considered throughout development:
- Validate all inputs at system boundaries
- Use parameterized queries for database access
- Store secrets in environment variables or secret managers
- Keep dependencies updated to patch vulnerabilities
- Apply the principle of least privilege
Performance Optimization
Optimize based on measured data, not assumptions:
- Profile before optimizing — identify actual bottlenecks
- Measure the impact of each change
- Consider the trade-off between speed and readability
- Cache expensive operations with appropriate invalidation
- Use connection pooling for database and network resources
Monitoring and Observability
Production systems need visibility:
- Structured logging with correlation IDs for request tracking
- Metrics for latency, throughput, error rates, and resource usage
- Health check endpoints for load balancers and orchestration
- Distributed tracing for request flows across services
- Alerts for anomaly detection based on baselines
These patterns apply across all programming languages and frameworks. The specific implementation varies, but the principles remain consistent.
FAQ
Q: Is JavaScript truly single-threaded? A: JavaScript language execution is single-threaded. The runtime (browser/Node) uses worker threads (Web Workers, Worker Threads) for parallel operations, but they do not share memory or access the DOM.
Q: What happens if a microtask queues another microtask? A: The microtask queue is drained completely before the next macrotask. New microtasks queued during draining are added to the current batch and executed in the same drain cycle.
Q: How does async/await relate to the event loop?
A: await suspends execution of the async function and returns control to the event loop. When the awaited promise settles, the continuation is queued as a microtask.
Q: What blocks the event loop? A: Long synchronous operations, infinite loops, heavy computation, JSON.parse on massive strings, and synchronous file reads (Node.js).
Q: What is the difference between setTimeout and setInterval?
A: setTimeout runs the callback once after the delay. setInterval runs the callback repeatedly with the delay between invocations. setInterval does not wait for the callback to complete before starting the next timer.
Q: Does Web Workers use the event loop? A: Yes, each Web Worker has its own event loop, call stack, and task queues. Workers do not share the main thread’s event loop or have access to the DOM.
For a comprehensive overview, read our article on Javascript Arrays Guide.
For a comprehensive overview, read our article on Javascript Async Await.