// blog/developer/
Back to Blog
Developer · August 28, 2026 · 11 min read · Updated May 22, 2026

JavaScript Event Loop: How Async Code Actually Runs

JavaScript Event Loop: How Async Code Actually Runs

JavaScript is single-threaded. One call stack, one thing at a time, no two pieces of code running at once. Yet it juggles thousands of concurrent network requests, animations, clicks, and timers without freezing the browser. How?

The event loop is the mechanism behind it. It is not part of the JavaScript language itself. It belongs to the runtime (the browser or Node.js). Understanding it is the difference between async code that behaves and async code that surprises you.

Most developers have a fuzzy mental model. They know setTimeout defers execution and Promises resolve "later." But the order in which "later" happens, and why some code runs before code that was scheduled earlier, stays mysterious. This guide spells out the full model with examples you can run in your browser console.

* * *

The Call Stack: One Thing at a Time

The call stack is a data structure that tracks which function is currently executing and which functions called it. When a function is called, it is pushed onto the stack. When it returns, it is 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); `

The call stack at each step: 1. printSquare(4) is pushed 2. square(4) is pushed (called by printSquare) 3. multiply(4, 4) is pushed (called by square) 4. multiply returns 16, popped 5. square returns 16, popped 6. console.log(16) is pushed, executes, popped 7. printSquare returns, popped 8. Stack is empty

If any function takes a long time (a heavy calculation, a synchronous network request), the entire stack is blocked. Nothing else can execute. The browser cannot handle clicks, render animations, or respond to any user input until the stack is clear.

This is why long-running synchronous code freezes the browser. The event loop cannot process new events until the call stack is empty.

Minify your JavaScript for production with the JavaScript Minifier to reduce file size without affecting the execution behavior of your async code.

* * *

Web APIs: Where Async Work Happens

When you call setTimeout, fetch, or addEventListener, the JavaScript engine does not handle the timer, network request, or event listening itself. It hands the work off to Web APIs provided by the browser (or C++ APIs in Node.js).

`javascript console.log('Start');

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

console.log('End'); `

Output: ` Start End Timeout callback `

Here is what happens:

  1. console.log('Start') runs immediately (synchronous)
  2. setTimeout is called. The browser starts a 1-second timer in the Web API layer. The setTimeout call itself returns immediately and is popped from the stack.
  3. console.log('End') runs immediately (synchronous)
  4. The stack is now empty
  5. After 1 second, the timer completes and the callback is placed in the task queue
  6. The event loop checks: is the stack empty? Yes. It moves the callback to the stack.
  7. console.log('Timeout callback') runs

The critical insight: the callback does not run after exactly 1 second. It runs after at least 1 second, when the call stack is empty. If the stack is busy with a long-running synchronous operation, the callback waits.

Format your async code consistently with the Code Formatter to make the flow of callbacks, Promises, and async/await easier to follow.

Diagram showing call stack, web APIs, and callback queue interaction
Diagram showing call stack, web APIs, and callback queue interaction
* * *

Microtasks vs Macrotasks: The Priority Queue

Not all asynchronous callbacks are treated equally. The event loop distinguishes between macrotasks and microtasks.

Macrotasks (task queue): - setTimeout / setInterval callbacks - I/O operations - UI rendering - MessageChannel - requestAnimationFrame

Microtasks (microtask queue): - Promise .then() / .catch() / .finally() callbacks - MutationObserver callbacks - queueMicrotask()

The event loop processes all microtasks before moving to the next macrotask. After each macrotask completes, the event loop drains the entire microtask queue before doing anything else.

`javascript console.log('1: script start');

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

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

console.log('4: script end'); `

Output: ` 1: script start 4: script end 3: Promise 2: setTimeout `

Why does the Promise resolve before the setTimeout, even though both are scheduled with zero delay?

  1. The main script is itself a macrotask. "1: script start" and "4: script end" run synchronously.
  2. After the main script macrotask completes, the event loop drains the microtask queue. The Promise callback is a microtask. "3: Promise" runs.
  3. Only then does the event loop pick up the next macrotask. The setTimeout callback runs. "2: setTimeout" appears last.

This priority system is why Promise.resolve().then() always runs before a setTimeout(fn, 0).

* * *

Why setTimeout(fn, 0) Is Not Instant

setTimeout(fn, 0) does not mean "run immediately." It means "run as soon as the current task and all microtasks are done, and it is this callback's turn in the task queue."

In practice, setTimeout(fn, 0) has a minimum delay of approximately 4ms in most browsers (the HTML spec allows a minimum of 4ms for nested timeouts beyond 5 levels of nesting). In Node.js, setImmediate(fn) is the equivalent with slightly different timing.

Common uses of setTimeout(fn, 0):

Yielding to the browser: breaking up long-running code to let the browser render and handle user input between chunks.

`javascript function processLargeArray(items) { let i = 0; function chunk() { const end = Math.min(i + 100, items.length); while (i < end) { processItem(items[i]); i++; } if (i < items.length) { setTimeout(chunk, 0); // yield, then continue } } chunk(); } `

Deferring DOM updates: ensuring that DOM changes from the current synchronous code are rendered before running additional logic.

Scheduling after microtasks: when you specifically want code to run after all Promises have resolved.

Modern alternatives to setTimeout(fn, 0): - requestAnimationFrame: runs before the next paint, ideal for visual updates - requestIdleCallback: runs when the browser is idle, ideal for non-urgent background work - scheduler.yield() (experimental): explicitly yields to the browser for input handling - queueMicrotask(): runs after the current task but before the next macrotask

Validate your JSON configurations that control async behavior with the JSON Formatter to ensure they are well-structured.

Key takeaway

`setTimeout(fn, 0)` does not mean "run immediately." It means "run as soon as the current task and all microtasks are done, and it is this callback's turn in the task queue." In practice, `setTimeout(fn, 0)` has a minimum delay of approximately 4ms in most browsers (the HTML spec allows a minimum of 4ms for nested timeouts beyond 5 levels of nesting).

* * *

Async/Await and the Event Loop

Async/await is syntactic sugar over Promises. Understanding this is essential for predicting execution order.

`javascript async function foo() { console.log('A'); await bar(); console.log('B'); }

async function bar() { console.log('C'); }

console.log('D'); foo(); console.log('E'); `

Output: ` D A C E B `

Here is why:

  1. console.log('D') runs synchronously
  2. foo() is called. Inside foo, console.log('A') runs synchronously.
  3. bar() is called. Inside bar, console.log('C') runs synchronously. bar() returns a resolved Promise.
  4. await pauses foo(). Everything after the await (console.log('B')) is scheduled as a microtask.
  5. Control returns to the caller. console.log('E') runs synchronously.
  6. The main script (macrotask) is done. The event loop drains microtasks.
  7. The continuation of foo() runs. console.log('B') executes.

The key takeaway: await does not block the thread. It yields control back to the caller and schedules the rest of the function as a microtask. This is why E appears before B even though foo() was called before console.log('E').

Common mistakes with async/await:

  • Forgetting that code after await runs later (not immediately)
  • Using await in a loop when Promise.all would be faster (sequential vs parallel)
  • Not catching errors with try/catch (unhandled Promise rejections)
  • Mixing callback-style and async/await style in the same function (hard to follow)
Console output demonstrating microtask versus macrotask execution order
Console output demonstrating microtask versus macrotask execution order
* * *

FAQ

Is the event loop part of the JavaScript language specification?

No. The ECMAScript specification defines the language (syntax, types, built-in objects) but not the event loop. The event loop is defined by the HTML Living Standard for browsers and by the libuv library for Node.js. Different environments can implement the event loop differently, which is why Node.js has setImmediate and process.nextTick while browsers do not.

Can microtasks starve macrotasks?

Yes. If microtasks continuously schedule more microtasks, the macrotask queue (and rendering) will be blocked indefinitely. For example, a recursive Promise.resolve().then(recurse) will freeze the browser because the event loop never finishes draining the microtask queue. Always ensure microtask chains terminate.

How does requestAnimationFrame fit into the event loop?

requestAnimationFrame callbacks run before the browser paints each frame (roughly every 16.7ms at 60fps). They are not macrotasks or microtasks. They occupy their own phase in the event loop, between microtask draining and rendering. This makes them ideal for visual updates because they are synchronized with the display refresh rate.

Does Web Workers change how the event loop works?

Web Workers have their own event loop running on a separate thread. They do not share the main thread's call stack or event loop. Communication between the main thread and a Worker happens through message passing (postMessage), which adds messages to each thread's respective task queue. Workers solve the problem of CPU-intensive tasks blocking the main thread.

Key takeaway

### Is the event loop part of the JavaScript language specification.