Event Loop in JavaScript: How Async Really Works

Published: (February 9, 2026 at 03:14 PM EST)
10 min read
Source: Dev.to

Source: Dev.to – Event Loop in JavaScript: How Async Really Works

The Event Loop – How Asynchronous JavaScript Really Works

JavaScript is single‑threaded: there is only one call stack and one thread executing your code at any given moment. Yet you can make HTTP requests, set timers, handle user clicks, and your code doesn’t freeze while waiting for those operations.

How? The Event Loop.

Most explanations show a diagram with boxes and arrows. Below we dive deeper and look at what actually happens when your async code runs.

The Mental Model: JavaScript Runtime Architecture

JavaScript never runs in isolation. The runtime (browser or Node.js) consists of four main parts:

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

    • Call Stack – where synchronous code executes.
    • Heap – where objects live.
  2. Web APIs / C++ APIs (provided by the browser or 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.

Key point: When you call setTimeout, you’re not invoking a JavaScript function; you’re invoking 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

Conceptually, the event loop is an infinite loop that repeatedly does the following:

while (true) {
  if (callStack.isEmpty()) {
    if (microtaskQueue.hasItems()) {
      microtaskQueue.executeNext();   // run one microtask
    } else if (macrotaskQueue.hasItems()) {
      macrotaskQueue.executeNext();   // run one macrotask
    }
  }
}

In words: “If the call stack is empty, first drain the microtask queue; if that’s empty, take the next macrotask.”

A Real‑World Example

console.log('Start');

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

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

console.log('End');

Expected output

Start
End
Promise
Timeout

Let’s see why.


Phase‑by‑Phase Execution

Phase 1 – Synchronous code runs

console.log('Start'); // prints "Start"

Call stack: [console.log] → executes → pops


Phase 2 – setTimeout

setTimeout(() => {
  console.log('Timeout');
}, 0);
  1. setTimeout is pushed onto the call stack.
  2. The browser’s timer API is invoked (runs on a separate thread).
  3. setTimeout returns immediately and is popped off.
  4. The timer starts a 0 ms countdown. When it expires, the callback is queued in the macrotask queue.

Call stack: [setTimeout] → pops
Macrotask queue: [] (callback will appear after ~0 ms)


Phase 3 – Promise

Promise.resolve().then(() => {
  console.log('Promise');
});
  1. Promise.resolve() creates an already‑resolved promise.
  2. .then() schedules its callback in the microtask queue.

Call stack: [Promise.resolve, .then] → pops
Microtask queue: [() => console.log('Promise')]


Phase 4 – More synchronous code

console.log('End'); // prints "End"

Call stack: [console.log] → executes → pops


Phase 5 – Call stack empty → Event loop runs

  1. Microtasks first – the Promise callback is moved to the call stack, runs, prints “Promise”, and clears the microtask queue.
  2. Then macrotasks – the timer callback is taken from the macrotask queue, runs, and prints “Timeout”.

Final output:

Start
End
Promise
Timeout

Microtasks vs. Macrotasks: The Priority System

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

Macrotasks (Task Queue)Microtasks
setTimeoutPromise.then / catch / finally
setIntervalqueueMicrotask()
setImmediate (Node only)MutationObserver (browser)
I/O callbacks (Node)process.nextTick (Node – even higher priority)
UI rendering (browser)

Priority example

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

Expected output

Promise 1
Promise 3
Promise 2
Timeout 1
Timeout 2

Why this order?

  1. Synchronous code runs first (nothing is logged yet).
  2. The call stack empties, so the event loop drains the microtask queue:
    • Promise 1 runs and queues Promise 2.
    • Promise 3 runs.
    • Promise 2 runs (the microtask queue must be empty before any macrotask).
  3. Only after the microtask queue is empty does the loop process macrotasks: Timeout 1 then Timeout 2.

Node.js Event Loop – More Complex Phases

Node.js splits the event loop into a series of phases that run in a fixed order on every tick:

┌─────────────────────────────┐
│          timers               │ ← setTimeout, setInterval
├─────────────────────────────┤
│   pending callbacks           │ ← I/O callbacks deferred from previous tick
├─────────────────────────────┤
│   idle, prepare               │ ← internal use only
├─────────────────────────────┤
│   poll                        │ ← retrieve new I/O events; execute I/O callbacks
├─────────────────────────────┤
│   check                       │ ← setImmediate callbacks
├─────────────────────────────┤
│   close callbacks             │ ← e.g., socket.on('close')
└─────────────────────────────┘

Each phase has its own queue(s). After a phase finishes, Node checks the microtask queue (promises, process.nextTick) before moving to the next phase.
This fine‑grained ordering explains why setImmediate and process.nextTick behave differently from their browser counterparts.


TL;DR

ConceptWhat it does
Call stackExecutes synchronous code.
Web APIs / C++ APIsPerform work off the main thread (e.g., I/O, timers).
Task queuesHold callbacks (macrotasks) and microtasks.
Event loopWhen the stack is empty, it drains the microtask queue first, then a macrotask, and repeats.

Understanding this flow lets you reason about ordering, performance, and potential pitfalls in both browser and Node.js environments.


Node.js Event Loop – Visual Guide

┌───────────────────────────────────────┐
│               timers                   │ ← setTimeout / setInterval
├───────────────────────────────────────┤
│          pending callbacks              │ ← I/O callbacks from previous tick
├───────────────────────────────────────┤
│            idle, prepare                │ ← internal use only
├───────────────────────────────────────┤
│                poll                     │ ← retrieve new I/O events; execute I/O callbacks
├───────────────────────────────────────┤
│               check                     │ ← setImmediate callbacks
├───────────────────────────────────────┤
│          close callbacks                │ ← e.g., socket.on('close')
└───────────────────────────────────────┘

Between each phase: All microtasks (promises, process.nextTick) are processed before the next phase begins.


Use this diagram as a quick reference when debugging timing‑related issues or when deciding whether to use setTimeout, setImmediate, or process.nextTick in your Node.js code.

Timers Phase

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

If the event loop is busy (e.g., executing a long‑running task), the callback may run after 150 ms, 200 ms, etc.

Poll Phase

This is where Node.js spends most of its time.

  • Executes I/O callbacks (file reads, network requests, …).
  • If there are no callbacks, it waits for new events (blocking, but can be interrupted).

When an I/O operation finishes (e.g., a file read or an HTTP response), its callback is placed in the poll queue.


Check Phase – setImmediate

setImmediate behaves like setTimeout(fn, 0), but it always runs after the poll phase.

const fs = require('fs');

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

Output

setImmediate
setTimeout

Why?
When the callback is executed inside an I/O operation (the poll phase), setImmediate is scheduled for the next check phase, whereas setTimeout must wait for the next timers phase (the following loop cycle).

process.nextTick – The Priority Microtask

In Node.js, process.nextTick has higher priority than regular microtasks.

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

Output

nextTick
Promise

Execution order in Node.js

  1. Synchronous code
  2. process.nextTick queue – drained completely
  3. Microtask queue – drained completely
  4. Next event‑loop phase

Warning: You can starve the event loop.

function recurse() {
  process.nextTick(recurse);
}
recurse(); // Blocks the loop forever

The nextTick queue never empties, so I/O callbacks never run.

I/O Operations – How They Actually Work

const fs = require('fs');

console.log('Start');

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

console.log('End');

What Happens

  1. fs.readFile is called – the synchronous part of the function runs.
  2. Node.js hands the work off to libuv (the C++ layer).
  3. libuv uses the operating system’s async I/O facilities or a thread pool.
  4. fs.readFile returns immediately (non‑blocking).
  5. JavaScript continues executing the next line (console.log('End')).
  6. When the file read completes, libuv notifies the event loop.
  7. The callback is placed in the poll queue.
  8. The event loop picks the callback from the queue and executes it.

Output

Start
End
File read

The I/O itself runs in parallel (on a separate thread or via OS mechanisms), but the callback is always executed 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');

Output

5
4
3
1
2

Execution order

StepWhat runsReason
15Synchronous code
24process.nextTick queue
33Microtask queue (Promise)
41Macrotask – timers phase
52Check phase (setImmediate)

Note: Outside an I/O callback, the relative 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');

Execution trace

PhaseOutput
SyncA, F
MicrotasksD, E
Macrotask (timer)B
Microtasks (after timer)C

Final 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!

The callback runs after all synchronous code finishes.

2. Infinite microtask loops

function loop() {
  Promise.resolve().then(loop);
}
loop(); // Blocks the event loop!

Each promise schedules another microtask, starving macrotasks.

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) {}

The setTimeout callback won’t fire until the blocking loop ends.


Takeaway: Understanding the exact order of synchronous code, process.nextTick, microtasks, and the various macrotask phases (timers, poll, check, close) is essential for writing correct, performant Node.js applications.

Summary

The event loop is JavaScript’s solution to single‑threaded asynchronous execution.

Key Points

  • Synchronous code runs first.
  • Async APIs (timers, I/O, etc.) execute outside the JavaScript engine and notify it when they finish.
  • Callbacks are placed in queues:
    • Microtasks – e.g., Promise callbacks, process.nextTick.
    • Macrotasks – e.g., setTimeout, I/O callbacks.
  • The event loop processes all microtasks before any macrotask.
  • In Node.js the loop consists of distinct phases (timers → poll → check), with microtasks running between phases.
  • process.nextTick has the highest priority (use it sparingly).

Mental Model

  1. Run all synchronous code to completion.
  2. Drain the microtask queue.
  3. Pick one macrotask and execute it.
  4. Drain the microtask queue again.
  5. Repeat from step 2.

Why It Matters

  • Predict execution order of setTimeout, Promise, etc.
  • Avoid blocking the event loop with long‑running sync code.
  • Debug async race conditions more easily.
  • Write performant async code by leveraging the right queue.

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

0 views
Back to Blog

Related posts

Read more »