JavaScript Visualized: Promises and Async/Await

Hoang Vu,4 min read

March 25, 2026

This article focuses on visual mental models and practical debugging checklists you can use on real production async flows.


1. Why Promises matter

Promises are JavaScript's standard abstraction for values that may arrive later.

Without Promises, async code quickly degrades into nested callbacks with poor composability. With Promises, we get:

  1. deterministic state transitions,
  2. chainable control flow,
  3. standardized error propagation,
  4. interoperability with async/await.

What is a callback and why does it get messy?

A callback is a function passed into another function and called later when work completes.

fetchUser((err, user) => {
  if (err) return handleError(err);
  fetchPosts(user.id, (err2, posts) => {
    if (err2) return handleError(err2);
    fetchComments(posts[0].id, (err3, comments) => {
      if (err3) return handleError(err3);
      render(comments);
    });
  });
});

This pattern works, but quickly turns into callback hell when dependency chains grow:

  1. deeply nested control flow,
  2. scattered error handling,
  3. hard-to-test, hard-to-refactor logic.

Promises solve this by enabling linear chaining and consistent error propagation.


2. Promise state machine

A Promise has one-way state transitions:

  1. pending -> fulfilled
  2. pending -> rejected

Once settled, it cannot return to pending or switch to the other outcome.

PROMISE STATE MACHINE

pending

fulfilled

rejected

Code Mapping

const p = fetch('/api/profile');
p.then((data) => renderProfile(data))
.catch((error) => reportError(error));

pending: Promise is still waiting for an async result.

Current: pending

That immutability is what makes Promise chains predictable.


3. Then/catch/finally behavior

then, catch, and finally always return a new Promise.

That means every handler creates a new stage in the chain:

  1. return plain value -> next Promise resolves with that value,
  2. throw error -> next Promise rejects,
  3. return Promise -> chain waits for it.

This mechanism is the core reason Promise composition is so powerful.


4. Event loop and microtasks

Many bugs around Promises are actually event loop misunderstandings.

High-level rule:

  1. current synchronous call stack finishes,
  2. microtask queue (Promise.then, queueMicrotask) is drained,
  3. next task queue item (timer, I/O callback, UI event) runs.

EVENT LOOP: TASK VS MICROTASK

Task Queue
setTimeout callback
UI event callback
network callback
Microtask Queue
promise.then
queueMicrotask
mutation observer

Code Mapping

console.log('start');
setTimeout(() => console.log('task'), 0);
Promise.resolve().then(() => console.log('microtask'));
console.log('end');

Microtask is executed before the next task.

Microtasks are drained before the next task is picked.

This is why Promise callbacks often run before setTimeout(fn, 0).

Quiz example: why is the output A D C B?

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

EVENT LOOP QUIZ: WHY A D C B

Code
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');

Step 1: Synchronous line runs first: log A.

Microtask Queue

(empty)

Task Queue

(empty)

Console Output

A

Final order: A D C B

Quick explanation:

  1. A and D are synchronous, so they run immediately on the Call Stack.
  2. Promise.then goes to the Microtask Queue, which has higher priority.
  3. setTimeout callback goes to the Task Queue and runs after microtasks are drained.
  4. Final order is A D C B.

5. Async/await is Promise syntax sugar

async/await does not replace Promises; it expresses them in sequential style.

When execution hits await:

  1. function pauses,
  2. control returns to caller,
  3. continuation is queued as a microtask after awaited Promise settles.

ASYNC/AWAIT EXECUTION FLOW

1. Function starts
2. await promise
3. resume in microtask
4. continue after await

Code Mapping

async function loadUser() {
const res = await fetch('/api/user');
const data = await res.json();
return data;
}

Function enters and returns a Promise immediately.

So await feels synchronous to read, but remains asynchronous at runtime.


6. Common pitfalls in production

  1. Missing await accidentally returns unresolved Promise and skips expected flow.
  2. await inside loops causing unnecessary serialization.
  3. Mixed .then() and await chains reducing readability and error clarity.
  4. Swallowed errors with empty catch blocks.
  5. Unhandled rejections that never reach monitoring.

7. Practical patterns

Core Promise combinators

Quick selection guide:

  1. Need all to succeed: use all.
  2. Need full success/failure report: use allSettled.
  3. Need first settled result (success or failure): use race.
  4. Need first successful result: use any.

Promise.all([...]) - all-or-nothing

Behavior:

  1. Resolves only when every input fulfills.
  2. Rejects immediately on the first rejection (fail-fast).
  3. Output order matches input order, not completion order.

When to use:

  1. A page can render only if user, permissions, and settings all exist.
  2. A pipeline where any missing step invalidates the final output.

Common pitfall:

  1. Other Promises are not auto-cancelled after first rejection; all just rejects early.
const [user, perms, settings] = await Promise.all([
  fetchUser(),
  fetchPermissions(),
  fetchSettings(),
]);

Promise.allSettled([...]) - full outcome report

Behavior:

  1. Waits for all inputs to settle.
  2. Does not fail fast.
  3. Returns entries shaped as: {"status":"fulfilled","value":...} or {"status":"rejected","reason":...}.

When to use:

  1. Calling multiple independent services where partial data is still useful.
  2. Batch jobs where you want retry decisions per item.
const settled = await Promise.allSettled([
  fetchAnalytics(),
  fetchRecommendations(),
  fetchNotifications(),
]);
 
const ok = settled
  .filter((r): r is PromiseFulfilledResult<unknown> => r.status === "fulfilled")
  .map((r) => r.value);

Promise.race([...]) - first settled wins

Behavior:

  1. The first settled Promise decides the result.
  2. If the first settled one rejects, race rejects.

When to use:

  1. Timeout guards around slow calls.
  2. Fastest-source wins patterns where failure can also happen first.
const data = await Promise.race([
  fetch("/api/heavy").then((r) => r.json()),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Timeout after 1500ms")), 1500)
  ),
]);

Promise.any([...]) - first fulfilled wins

Behavior:

  1. Resolves with the first fulfilled Promise.
  2. Ignores intermediate rejections.
  3. Rejects with AggregateError only if all inputs reject.

When to use:

  1. Multi-region fallback.
  2. Multi-CDN failover where any healthy source is enough.
const profile = await Promise.any([
  fetchFromRegion("ap-southeast"),
  fetchFromRegion("us-east"),
  fetchFromRegion("eu-west"),
]);
try {
  await Promise.any([svcA(), svcB(), svcC()]);
} catch (err) {
  if (err instanceof AggregateError) {
    console.error("All providers failed", err.errors);
  }
}

Parallel execution

const [user, posts, settings] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchSettings(),
]);

Tolerant fan-out

const results = await Promise.allSettled([
  fetchServiceA(),
  fetchServiceB(),
  fetchServiceC(),
]);

Explicit timeout wrapper

function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
  return Promise.race([
    p,
    new Promise<T>((_, reject) =>
      setTimeout(() => reject(new Error("Timeout")), ms)
    ),
  ]);
}

8. Mental model to keep

Think of Promise and async/await in three layers:

  1. Promise state machine (pending -> fulfilled/rejected),
  2. scheduling semantics (microtask before next task),
  3. composition strategy (parallel, retry, timeout, fallback).

If you reason from these three layers, most async bugs become straightforward to diagnose.

2026 © @hoag/blog.