DEV Community

Cover image for Event Loop in JavaScript: How Async Really Works
Razumovsky
Razumovsky

Posted on

Event Loop in JavaScript: How Async Really Works

JavaScript is single-threaded. There's only one call stack, one thread executing your code at any given time. Yet somehow you can make HTTP requests, set timers, handle user clicks, and your code doesn't freeze waiting for these operations.

How? The Event Loop.

Most explanations show you a diagram with boxes and arrows. We'll go deeper - let's understand what's actually happening when your async code runs.

The Mental Model: JavaScript Runtime Architecture

JavaScript doesn't run in isolation. The runtime (browser or Node.js) consists of:

1. The JavaScript Engine (V8, SpiderMonkey, etc.)

  • Call Stack: Where your synchronous code executes
  • Heap: Where objects live

2. Web APIs / C++ APIs (browser/Node.js)

  • Timer APIs (setTimeout, setInterval)
  • Network APIs (fetch, XMLHttpRequest)
  • File system, crypto, etc. (Node.js)

3. Task Queues

  • Macrotask Queue (also called Task Queue or Callback Queue)
  • Microtask Queue

4. The Event Loop

  • Coordinates everything

Here's the crucial part: When you call setTimeout, you're not calling a JavaScript function. You're calling a Web API that runs outside the JavaScript engine. The event loop bridges the gap between the engine and these external APIs.

The Event Loop: What It Actually Does

The event loop is a simple infinite loop that does one thing:

while (true) {
  if (callStack.isEmpty()) {
    if (microtaskQueue.hasItems()) {
      microtaskQueue.executeNext();
    } else if (macrotaskQueue.hasItems()) {
      macrotaskQueue.executeNext();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That's it. The event loop constantly checks: "Is the call stack empty? If yes, is there work in the queues?"

Let's trace through a real example:

console.log('Start');

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

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

console.log('End');
Enter fullscreen mode Exit fullscreen mode

Output:

Start
End
Promise
Timeout
Enter fullscreen mode Exit fullscreen mode

Let's see why.

Phase-by-Phase Execution

Phase 1: Synchronous Code Runs

console.log('Start'); // Executes immediately
Enter fullscreen mode Exit fullscreen mode

Call Stack: [console.log] → executes → prints "Start" → pops off

Phase 2: setTimeout

setTimeout(() => { console.log('Timeout'); }, 0);
Enter fullscreen mode Exit fullscreen mode

This doesn't execute the callback. Instead:

  1. setTimeout is called (pushed to call stack)
  2. The browser's timer API is invoked with the callback
  3. setTimeout immediately returns and is popped from the stack
  4. The timer API starts a 0ms timer in the background (separate thread)
  5. When the timer expires, the callback is placed in the Macrotask Queue

Call Stack: [setTimeout] → pops off
Macrotask Queue: [] (callback will arrive here after ~0ms)

Phase 3: Promise

Promise.resolve().then(() => { console.log('Promise'); });
Enter fullscreen mode Exit fullscreen mode
  1. Promise.resolve() creates an already-resolved promise
  2. .then() schedules a callback
  3. The callback goes into the Microtask Queue (not macrotask!)

Call Stack: [Promise.resolve, .then] → pops off
Microtask Queue: [() => console.log('Promise')]

Phase 4: More Synchronous Code

console.log('End');
Enter fullscreen mode Exit fullscreen mode

Call Stack: [console.log] → executes → prints "End" → pops off

Phase 5: Call Stack Empty - Event Loop Wakes Up

Now the call stack is empty. The event loop starts working:

  1. Check Microtask Queue first (always has priority!)

    • Found: () => console.log('Promise')
    • Push to call stack → execute → prints "Promise"
    • Microtask Queue is now empty
  2. Check Macrotask Queue

    • By now, the timer has expired and placed its callback here
    • Found: () => console.log('Timeout')
    • Push to call stack → execute → prints "Timeout"

Final output: Start, End, Promise, Timeout

Microtasks vs Macrotasks: The Priority System

The event loop always processes all microtasks before moving to the next macrotask.

Macrotasks (Task Queue):

  • setTimeout
  • setInterval
  • setImmediate (Node.js only)
  • I/O operations
  • UI rendering (browser)

Microtasks:

  • Promise.then/catch/finally
  • queueMicrotask()
  • MutationObserver (browser)
  • process.nextTick (Node.js - special case, even higher priority!)

Example showing priority:

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

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

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

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

Promise.resolve().then(() => {
  console.log('Promise 3');
});
Enter fullscreen mode Exit fullscreen mode

Output:

Promise 1
Promise 3
Promise 2
Timeout 1
Timeout 2
Enter fullscreen mode Exit fullscreen mode

Why?

  1. All synchronous code runs first (nothing to log)
  2. Call stack empty → Event loop checks microtasks
  3. Promise 1 executes, schedules Promise 2 (added to microtask queue)
  4. Promise 3 executes
  5. Promise 2 executes (microtask queue must be empty before macrotasks!)
  6. Now microtask queue is empty → move to macrotasks
  7. Timeout 1 executes
  8. Timeout 2 executes

Node.js Event Loop: More Complex Phases

In Node.js, the event loop has specific phases that execute in order:

   ┌───────────────────────────┐
┌─>│           timers          │ <- setTimeout, setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │ <- I/O callbacks deferred from previous cycle
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │ <- internal use only
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │ <- retrieve new I/O events
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │ <- setImmediate callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │ <- socket.on('close', ...)
│  └───────────────────────────┘
└──────────────────────────────────
Enter fullscreen mode Exit fullscreen mode

Between each phase: All microtasks are processed.

Timers Phase

Executes callbacks scheduled by setTimeout and setInterval whose timers have expired.

Important: The timer specifies the minimum delay, not a guarantee:

setTimeout(() => console.log('100ms'), 100);
Enter fullscreen mode Exit fullscreen mode

If the event loop is busy (executing a long task), the callback might run after 150ms, 200ms, etc.

Poll Phase

This is where Node.js spends most of its time. The poll phase:

  1. Executes I/O callbacks (file reads, network requests, etc.)
  2. If no callbacks, waits for new events (blocking, but can be interrupted)

When I/O completes (file read finishes, HTTP response arrives), the callback is placed in the poll queue.

Check Phase: setImmediate

setImmediate is like setTimeout(fn, 0) but with a guarantee: it always runs after the poll phase.

const fs = require('fs');

fs.readFile('file.txt', () => {
  setTimeout(() => console.log('setTimeout'), 0);
  setImmediate(() => console.log('setImmediate'));
});
Enter fullscreen mode Exit fullscreen mode

Output:

setImmediate
setTimeout
Enter fullscreen mode Exit fullscreen mode

Why? Inside an I/O callback (poll phase), setImmediate executes in the next check phase, while setTimeout has to wait until the next timers phase (next cycle of the event loop).

process.nextTick: The Priority Microtask

In Node.js, process.nextTick is special. It has even higher priority than regular microtasks:

Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
Enter fullscreen mode Exit fullscreen mode

Output:

nextTick
Promise
Enter fullscreen mode Exit fullscreen mode

The execution order in Node.js:

  1. Synchronous code
  2. process.nextTick queue (completely drain)
  3. Microtask queue (completely drain)
  4. Next event loop phase

Warning: You can starve the event loop:

function recurse() {
  process.nextTick(recurse);
}
recurse();
Enter fullscreen mode Exit fullscreen mode

This will block the event loop forever because the nextTick queue never empties - I/O callbacks will never run!

I/O Operations: How They Actually Work

When you read a file in Node.js:

const fs = require('fs');

console.log('Start');

fs.readFile('data.txt', (err, data) => {
  console.log('File read');
});

console.log('End');
Enter fullscreen mode Exit fullscreen mode

What happens:

  1. fs.readFile is called (synchronous part)
  2. Node.js delegates to libuv (C++ layer)
  3. libuv uses the OS's async I/O or a thread pool
  4. fs.readFile returns immediately (non-blocking)
  5. JavaScript continues executing (console.log('End'))
  6. When the file read completes (in background), libuv notifies the event loop
  7. The callback is placed in the poll queue
  8. Event loop picks it up and executes it

Output:

Start
End
File read
Enter fullscreen mode Exit fullscreen mode

The I/O happens in parallel (separate thread/OS), but the callback runs on the main JavaScript thread.

Practical Example: Ordering Chaos

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

setImmediate(() => console.log('2'));

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

process.nextTick(() => console.log('4'));

console.log('5');
Enter fullscreen mode Exit fullscreen mode

Output:

5
4
3
1
2
Enter fullscreen mode Exit fullscreen mode

Execution order:

  1. Synchronous: 5
  2. nextTick queue: 4
  3. Microtask queue: 3
  4. Macrotask (timer phase): 1
  5. Check phase: 2

(Note: Outside I/O callbacks, the order of setTimeout vs setImmediate can vary)

Visualizing Async Flow

console.log('A');

setTimeout(() => {
  console.log('B');
  Promise.resolve().then(() => console.log('C'));
}, 0);

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

console.log('F');
Enter fullscreen mode Exit fullscreen mode

Execution trace:

Sync Phase:

  • A prints
  • setTimeout registers callback in macrotask queue
  • Promise chain starts, D callback → microtask queue
  • F prints

Microtask Phase:

  • D prints, schedules E → microtask queue
  • E prints
  • Microtask queue empty

Macrotask Phase:

  • setTimeout callback executes
  • B prints
  • Promise callback → microtask queue

Microtask Phase (again!):

  • C prints

Output: A, F, D, E, B, C

Common Pitfalls

1. Assuming setTimeout(fn, 0) runs immediately:

let x = 0;
setTimeout(() => { x = 1; }, 0);
console.log(x); // 0, not 1!
Enter fullscreen mode Exit fullscreen mode

The callback runs after synchronous code completes.

2. Infinite microtask loops:

function loop() {
  Promise.resolve().then(loop);
}
loop(); // Blocks event loop!
Enter fullscreen mode Exit fullscreen mode

Each promise schedules another microtask. Macrotasks never get a chance to run.

3. Heavy computation blocking the loop:

setTimeout(() => console.log('Never runs?'), 100);

// Block for 5 seconds
const start = Date.now();
while (Date.now() - start < 5000) {}
Enter fullscreen mode Exit fullscreen mode

The setTimeout callback won't run until the while loop finishes, even though 100ms passed long ago.

Summary

The event loop is JavaScript's solution to single-threaded async:

Key Points:

  • JavaScript executes synchronous code first
  • Async APIs (timers, I/O) run outside JavaScript, notify when done
  • Callbacks go into queues: microtasks (promises) or macrotasks (setTimeout, I/O)
  • Event loop processes all microtasks before any macrotask
  • In Node.js: specific phases (timers → poll → check), microtasks run between phases
  • process.nextTick has highest priority (use carefully!)

Mental model:

  1. Sync code runs to completion
  2. Drain all microtasks
  3. Take one macrotask
  4. Drain all microtasks again
  5. Repeat

Understanding this helps you:

  • Predict execution order
  • Avoid blocking the event loop
  • Debug async race conditions
  • Write performant async code

Next time you see a setTimeout or a Promise, you'll know exactly when that code will run - and why.

Top comments (0)