frontend

Javascript Visualized Promise Execution

Updated: March 27, 2026

Javascript Visualized Promise Execution

TL;DR

JavaScript Promises execute asynchronously via the microtask queue—each .then() handler runs after the current task completes but before the next macrotask, giving Promises priority over setTimeout and other I/O operations.

Promises are fundamental to modern JavaScript, yet their execution model confuses many developers. Why does a .then() handler run before a setTimeout callback, even if the timeout is 0ms? How does async/await differ from Promises under the hood? This post demystifies Promise execution by visualizing the event loop, microtask queue, and macrotask queue—the mechanisms that give Promises their power and behavior.

The Event Loop: Macrotasks and Microtasks

JavaScript is single-threaded and uses an event loop to manage asynchronous operations. The loop processes two types of work:

Macrotasks (Task Queue): the initial script, setTimeout, setInterval, I/O, UI events Microtasks (Microtask Queue): Promise reactions, queueMicrotask(), MutationObserver

Per the HTML spec, each iteration of the event loop runs one task, drains the microtask queue completely, then performs any rendering update if there is a render opportunity, then loops. UI rendering only applies in browsers — Node.js has no rendering step.

Visual: Event Loop Execution Order

┌─────────────────────────────────────────────────────┐
│ 1. Run one task                                     │
│    (initial script, setTimeout cb, I/O cb, etc.)    │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 2. Drain the microtask queue                        │
│    (Promise reactions, queueMicrotask)              │
│    Includes microtasks queued by other microtasks.  │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 3. Render (browsers only, if there's an opportunity)│
│    (style, layout, paint — may be skipped)          │
└─────────────────────────────────────────────────────┘
│ [Loop back to step 1 with the next task]

The browser may skip rendering on a given iteration if nothing visual changed, or if it decides to coalesce frames. The microtask queue, however, is always drained to empty before the loop moves on.

Code Example: Execution Order

console.log('1. Sync start');

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

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

queueMicrotask(() => console.log('5. queueMicrotask'));

console.log('6. Sync end');

// Output:
// 1. Sync start
// 6. Sync end
// 3. Promise 1
// 5. queueMicrotask
// 4. Promise 2
// 2. setTimeout

// Why? Synchronous code runs first, then microtasks (Promises, queueMicrotask),
// then macrotasks (setTimeout).

Promise States and Transitions

A Promise starts pending and moves to either fulfilled or rejected, irreversibly.

// Creating and resolving a Promise
const promise = new Promise((resolve, reject) => {
  console.log('A. Promise constructor runs immediately');

  setTimeout(() => {
    console.log('B. Inside setTimeout');
    resolve('success'); // Transition to fulfilled
  }, 0);
});

console.log('C. After Promise declaration');

promise.then(
  result => console.log('D. .then handler:', result),
  error => console.log('Error handler:', error)
);

console.log('E. After .then');

// Output:
// A. Promise constructor runs immediately
// C. After Promise declaration
// E. After .then
// B. Inside setTimeout
// D. .then handler: success

// Explanation:
// 1. Promise constructor (A) runs synchronously
// 2. setTimeout callback is scheduled as macrotask
// 3. .then is registered but doesn't execute immediately
// 4. After sync code (C, E), event loop processes microtasks
// 5. setTimeout executes (B), which resolves the Promise
// 6. Promise resolution queues the .then handler (D) as microtask

Promise.then() Execution Flow

Each .then() call returns a new Promise. The handler's return value becomes the next Promise's fulfillment value.

const p = Promise.resolve(1)
  .then(x => {
    console.log('Handler 1:', x);
    return x + 1; // New Promise resolves with 2
  })
  .then(x => {
    console.log('Handler 2:', x);
    return x + 1; // New Promise resolves with 3
  })
  .then(x => {
    console.log('Handler 3:', x);
  });

// Output:
// Handler 1: 1
// Handler 2: 2
// Handler 3: 3

// Visual: Promise Chain
//
// Promise { 1 }
//     ↓ .then(handler1)
// [queue handler1 as microtask]
//     ↓ (after sync code)
// Handler 1: 1 (returns 2)
//     ↓ (new Promise { 2 })
// [queue handler2 as microtask]
//     ↓ (after handler1 completes)
// Handler 2: 2 (returns 3)
//     ↓ (new Promise { 3 })
// [queue handler3 as microtask]
//     ↓ (after handler2 completes)
// Handler 3: 3

Promise.withResolvers() (ES2024)

Before ES2024, creating a Promise with external resolution required storing resolve and reject references. Promise.withResolvers() simplifies this.

// Traditional approach (verbose)
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

// ES2024 approach (clean)
const { promise, resolve, reject } = Promise.withResolvers();

// Using it: external control
const { promise: p, resolve: r } = Promise.withResolvers();

document.getElementById('button').addEventListener('click', () => {
  r('Button clicked!');
});

const result = await p; // Waits for button click
console.log(result); // 'Button clicked!'

// Under the hood: Promise.withResolvers() is roughly equivalent to
// ┌──────────────────────────────────────────┐
// │ let resolve, reject;                     │
// │ const promise = new Promise((res, rej) =>│
// │   { resolve = res; reject = rej; });     │
// │ return { promise, resolve, reject };     │
// └──────────────────────────────────────────┘
//
// Browser support: all evergreen browsers since early 2024.
// Node.js: v22+ enables it by default (v21.7+ behind a flag).

Async/Await Under the Hood

async/await is syntactic sugar over Promises and microtasks. An async function always returns a Promise, and every await pauses the function and resumes it as a microtask once the awaited value settles.

// Async function
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();
  return user;
}

// Roughly equivalent Promise chain (rejections propagate through .then)
function fetchUserPromise(id) {
  return fetch(`/api/users/${id}`)
    .then(response => response.json());
}
// (The trailing `return user` simply forwards the value, so no extra .then is needed.)

// Execution order: await schedules continuation as a microtask
async function example() {
  console.log('1. Sync before await');

  const result = await Promise.resolve('async');
  console.log('2. After await:', result);

  return result;
}

example().then(() => console.log('3. After function'));

console.log('4. Sync after function call');

// Output:
// 1. Sync before await
// 4. Sync after function call
// 2. After await: async
// 3. After function

The synchronous prefix of an async function (everything before the first await) runs immediately when the function is called. At the first await, control returns to the caller — the rest of the function body resumes later as a microtask. V8 optimised this in 2018 so awaiting an already-resolved Promise costs a single microtask tick instead of three1.


Error Handling in Promise Chains

Errors propagate through Promise chains, and .catch() handlers are also microtasks.

Promise.resolve()
  .then(() => {
    console.log('Handler 1');
    throw new Error('Oops!');
  })
  .then(() => {
    console.log('Handler 2 (skipped)');
  })
  .catch(error => {
    console.log('Caught:', error.message);
    return 'recovered';
  })
  .then(result => {
    console.log('Handler 3:', result);
  });

// Output:
// Handler 1
// Caught: Oops!
// Handler 3: recovered

// Visualization: Error in Promise Chain
//
// Promise.resolve()
//     ↓
// [Handler 1 queued as microtask]
//     ↓ (execute)
// Handler 1 → throw Error
//     ↓ (state: rejected)
// [skip Handler 2]
//     ↓
// [.catch handler queued as microtask]
//     ↓ (execute)
// Caught: Oops! → return 'recovered'
//     ↓ (new Promise: fulfilled)
// [Handler 3 queued as microtask]
//     ↓ (execute)
// Handler 3: recovered

Promise.allSettled() vs Promise.all()

Promise.all() rejects as soon as any input Promise rejects (fail-fast). Promise.allSettled() waits for all of them and reports each settlement.

function makePromises() {
  return [
    Promise.resolve('a'),
    Promise.reject('b'),
    Promise.resolve('c')
  ];
}

// Promise.all: fails fast
Promise.all(makePromises()).catch(error => {
  console.log('Failed:', error); // 'b'
});

// Promise.allSettled: waits for all
Promise.allSettled(makePromises()).then(results => {
  console.log('Results:', results);
  // [
  //   { status: 'fulfilled', value: 'a' },
  //   { status: 'rejected', reason: 'b' },
  //   { status: 'fulfilled', value: 'c' }
  // ]
});

Promise.race() settles with the first input to settle (fulfilled or rejected). Promise.any() (ES2021) is the inverse of Promise.all() — it fulfils with the first fulfilled value and only rejects if every input rejects (with an AggregateError).

Unhandled Promise Rejections

If a Promise rejects and no .catch() is attached, JavaScript emits an error event.

// Unhandled rejection
Promise.reject('Oops!');

// Global handler (browser)
window.addEventListener('unhandledrejection', event => {
  console.error('Unhandled rejection:', event.reason);
  event.preventDefault(); // Prevent crash
});

// Global handler (Node.js)
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection:', reason);
});

// Attaching handler after delay avoids the error
setTimeout(() => {
  Promise.reject('Later error').catch(e => console.log(e));
}, 0);

Practical Pattern: Retry with Exponential Backoff

Understanding microtasks and Promise execution enables complex patterns.

async function retryWithBackoff(fn, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;

      const delay = Math.pow(2, attempt) * 100; // 100ms, 200ms, 400ms
      console.log(`Attempt ${attempt + 1} failed. Retrying in ${delay}ms`);

      // Wait using Promise (microtask) or setTimeout (macrotask)
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
retryWithBackoff(async () => {
  const response = await fetch('/api/data');
  if (!response.ok) throw new Error('HTTP error');
  return response.json();
}).then(data => console.log('Success:', data));

Conclusion

Promise execution is governed by the event loop's microtask queue. Every .then(), .catch(), and .finally() handler runs as a microtask, and the queue is drained completely between tasks — so a microtask queued during the current task always runs before the next setTimeout callback. async/await resumes through the same microtask machinery, so the same ordering applies. Master this model, and you'll understand race conditions, debug timing issues, and write reliable async code.

Footnotes

  1. V8 team, "Faster async functions and promises".