Back to Blog
Advanced2025-01-05

The JavaScript Event Loop: How Async Code Really Works

Understand how JavaScript handles asynchronous operations. Learn about the call stack, task queue, and microtask queue.

JavaScript is Single-Threaded

JavaScript runs on a single thread. This means it can only do one thing at a time. So how does it handle things like fetching data, timers, and user events without freezing?

The answer is the Event Loop.

The Key Players

1. Call Stack

The call stack tracks what function is currently running. When you call a function, it's pushed onto the stack. When it returns, it's popped off.

``javascript function multiply(a, b) { return a * b; }

function square(n) { return multiply(n, n); }

function printSquare(n) { const result = square(n); console.log(result); }

printSquare(4);

// Call stack progression: // 1. printSquare(4) // 2. printSquare(4) -> square(4) // 3. printSquare(4) -> square(4) -> multiply(4, 4) // 4. printSquare(4) -> square(4) (multiply returns) // 5. printSquare(4) (square returns) // 6. (empty) (printSquare returns) `

2. Web APIs / Node APIs

When you call setTimeout, fetch, or add an event listener, the browser or Node handles these operations outside JavaScript. They run in parallel!

`javascript console.log('Start');

setTimeout(() => { console.log('Timeout'); }, 0);

console.log('End');

// Output: // Start // End // Timeout (even with 0ms delay!) `

Why does "Timeout" come last? Because setTimeout's callback goes through the event loop.

3. Task Queue (Callback Queue)

When an async operation completes (like a timer), its callback is placed in the task queue. The event loop moves callbacks from the queue to the call stack when the stack is empty.

4. Microtask Queue

Promises use a special queue called the microtask queue. Microtasks have higher priority than regular tasks!

`javascript console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// Output: 1, 4, 3, 2 // Why? Microtasks (Promise) run before tasks (setTimeout) `

The Event Loop Algorithm

1. Execute synchronous code (call stack) 2. Call stack empty? Check microtask queue 3. Execute ALL microtasks 4. Execute ONE task from task queue 5. Repeat

`javascript console.log('Script start');

setTimeout(() => { console.log('setTimeout'); }, 0);

Promise.resolve() .then(() => console.log('Promise 1')) .then(() => console.log('Promise 2'));

console.log('Script end');

// Output: // Script start // Script end // Promise 1 // Promise 2 // setTimeout `

Let's trace through this:

1. Sync: "Script start" logged 2. Sync: setTimeout callback → Task Queue 3. Sync: Promise handlers → Microtask Queue 4. Sync: "Script end" logged 5. Stack empty: Run all microtasks - "Promise 1" logged - "Promise 2" logged (chained .then adds new microtask) 6. Microtasks done: Run one task - "setTimeout" logged

Common Patterns and Gotchas

setTimeout(fn, 0) Doesn't Mean Immediate

`javascript setTimeout(() => console.log('timeout'), 0); console.log('sync');

// Output: sync, timeout `

Zero delay means "run as soon as possible after current sync code and microtasks", not "run immediately".

Promises Always Async

`javascript const promise = Promise.resolve('done');

promise.then(console.log);

console.log('sync');

// Output: sync, done // Even though promise is already resolved! `

Blocking the Event Loop

`javascript // BAD: Blocks everything function heavyComputation() { for (let i = 0; i < 1e9; i++) { // Blocks for seconds! } }

heavyComputation(); // UI frozen, no events processed

// BETTER: Break into chunks function heavyComputationAsync(data, index = 0) { const chunkSize = 10000; const end = Math.min(index + chunkSize, data.length);

for (let i = index; i < end; i++) { // Process chunk }

if (end < data.length) { setTimeout(() => heavyComputationAsync(data, end), 0); } } `

async/await and the Event Loop

`javascript async function foo() { console.log('foo start'); await Promise.resolve(); console.log('foo end'); }

console.log('script start'); foo(); console.log('script end');

// Output: // script start // foo start // script end // foo end `

await pauses the function and puts the rest in the microtask queue.

Real-World Example: UI Updates

`javascript button.addEventListener('click', () => { // This runs as a task box.style.transform = 'translateX(100px)';

// Force browser to notice the change box.offsetHeight; // Trigger reflow

box.style.transition = 'transform 1s'; box.style.transform = 'translateX(200px)'; }); `

Visualizing the Event Loop

` +-------------------+ | Call Stack | | (Main Thread) | +---------+---------+ | v +-------------------+ | Event Loop | +---------+---------+ | +-----------+-----------+ | | v v +------+------+ +-------+------+ | Microtask | | Task | | Queue | | Queue | | (Promises) | | (setTimeout) | +-------------+ +--------------+ ``

Summary

- JavaScript is single-threaded but handles async via the event loop - Call Stack: What's currently executing - Task Queue: setTimeout, setInterval, events - Microtask Queue: Promises, queueMicrotask (higher priority) - Event loop: Move tasks to stack when stack is empty - Microtasks run before the next task - Never block the main thread with long sync operations