Academic Block

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');

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');

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');

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...

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

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

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.

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

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');

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
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.

📝 JavaScript Code
👁 Console Output

Click "Run Code" to see the output here.

Ctrl + Enter to run code Esc to close