Field note / javascript

publishedupdated 2026-05-01#javascript#async#promises#generators#microtask

Async JavaScript: Promises, async/await, Generators

Promise states and chaining, microtask queue execution order, Promise combinators (all/allSettled/race/any), AbortController, and async generators.

Overview

Async JavaScript is built on three layers: the event loop (scheduling), Promises (async value containers), and async/await (syntactic sugar for generator-based coroutines). Understanding the microtask queue — not just the API — is essential for predicting execution order and debugging subtle race conditions.


Architecture

EXECUTION ORDER: CALL STACK → MICROTASKS → TASKS
════════════════════════════════════════════════════════

  console.log('1')              // call stack → immediate
  setTimeout(() => log('2'), 0) // task queue (macrotask)
  Promise.resolve().then(() => log('3'))  // microtask queue
  console.log('4')              // call stack → immediate

  Output: 1, 4, 3, 2

  Why? After each task (including the initial script):
  1. All microtasks drain completely (Promise callbacks)
  2. Then next task runs (setTimeout callback)

  ┌─────────────────────────────────────────────────────┐
  │                     Call Stack                       │
  │  script() → console.log → setTimeout → Promise.then │
  └─────────────────────────────────────────────────────┘
           │ empties ↓              │ queued
  ┌────────────────────┐    ┌──────────────────────┐
  │   Microtask Queue  │    │    Task Queue         │
  │  Promise callbacks │    │  setTimeout callbacks │
  │  queueMicrotask()  │    │  setInterval          │
  │  MutationObserver  │    │  I/O events           │
  └────────────────────┘    └──────────────────────┘
       drains first ↑              runs after ↑

PROMISE COMBINATORS
════════════════════════════════════════════════════════

  Promise.all([p1, p2, p3])
    → resolves when ALL resolve (array of results)
    → rejects immediately on FIRST rejection (fail-fast)
    Use: parallel fan-out when all results required

  Promise.allSettled([p1, p2, p3])
    → always resolves with [{status, value/reason}] for ALL
    → never rejects
    Use: when you need results from all, even partial failures

  Promise.race([p1, p2, p3])
    → settles (resolve OR reject) with FIRST to settle
    Use: timeout pattern (race against a delay promise)

  Promise.any([p1, p2, p3])
    → resolves with FIRST to resolve, ignores rejections
    → rejects with AggregateError only if ALL reject
    Use: redundant requests — use fastest successful response

Technical Implementation

Promise.allSettled for Partial Success Handling

interface ApiResult<T> {
  status: 'fulfilled' | 'rejected';
  value?: T;
  reason?: Error;
}

async function fetchUserDashboard(userId: string) {
  const [profileResult, ordersResult, notificationsResult] = await Promise.allSettled([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchNotifications(userId),
  ]);

  // Process each independently — partial failures don't block the whole response
  return {
    profile: profileResult.status === 'fulfilled'
      ? profileResult.value
      : null,  // graceful degradation
    orders: ordersResult.status === 'fulfilled'
      ? ordersResult.value
      : [],
    notifications: notificationsResult.status === 'fulfilled'
      ? notificationsResult.value
      : [],
    errors: [profileResult, ordersResult, notificationsResult]
      .filter(r => r.status === 'rejected')
      .map(r => (r as PromiseRejectedResult).reason),
  };
}

AbortController for Fetch with Timeout

// Cancel a fetch if it takes too long — prevents resource leaks
function fetchWithTimeout<T>(url: string, options: RequestInit = {}, timeoutMs = 5000): Promise<T> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  return fetch(url, { ...options, signal: controller.signal })
    .then(async res => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json() as Promise<T>;
    })
    .finally(() => clearTimeout(timeoutId));
}

// Cancel all in-flight requests on component unmount (React)
function useData(url: string) {
  const [data, setData] = React.useState(null);

  React.useEffect(() => {
    const controller = new AbortController();

    fetch(url, { signal: controller.signal })
      .then(r => r.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err);  // ignore abort
      });

    return () => controller.abort();  // cleanup: cancel on unmount or url change
  }, [url]);

  return data;
}

Async Generator for Paginated API

// Async generator: yields pages lazily — no memory explosion loading all at once
async function* fetchAllOrders(userId: string): AsyncGenerator<Order[]> {
  let cursor: string | null = null;

  while (true) {
    const response = await fetch(`/api/orders?userId=${userId}&cursor=${cursor ?? ''}`);
    const { orders, nextCursor }: { orders: Order[]; nextCursor: string | null } = await response.json();

    yield orders;  // caller receives this page

    if (!nextCursor) break;
    cursor = nextCursor;
  }
}

// Consume with for await...of — processes one page at a time
async function exportAllOrders(userId: string): Promise<void> {
  for await (const page of fetchAllOrders(userId)) {
    await writeToCsv(page);  // process page before fetching next
    // only one page in memory at a time
  }
}

// Manual iteration with early exit
async function findFirstCancelledOrder(userId: string): Promise<Order | null> {
  for await (const page of fetchAllOrders(userId)) {
    const found = page.find(o => o.status === 'cancelled');
    if (found) return found;  // stops iteration immediately — no unnecessary fetches
  }
  return null;
}

Interview Preparation

Q: What is the microtask queue and why does it take priority over the task queue?

The microtask queue was introduced specifically for Promise callbacks to ensure they run as soon as possible — before any rendering, I/O callbacks, or timers. The design intent: when you .then() on a resolved Promise, the callback should run synchronously at the end of the current operation, not in some later tick. After any task completes (including the initial script execution), the JavaScript engine drains the entire microtask queue before picking up the next task. This means a long microtask chain (Promises resolving more Promises) can starve the task queue and block UI rendering — a common source of "jank" when misused.

Q: What happens if you await a non-Promise value?

await calls Promise.resolve() on the value first. await 42 is equivalent to await Promise.resolve(42) — the expression still suspends the function and puts the continuation in the microtask queue, even though the value is already available. This means await 42 is not truly synchronous — it yields control once. In practice: awaiting non-Promises is harmless but unnecessary. Awaiting undefined (forgetting await on an async call) is a common bug — the function continues immediately without waiting for the result.

Q: What is the difference between Promise.all and Promise.allSettled?

Promise.all fails fast — if any promise rejects, the whole all rejects immediately and the other results are discarded. Use it when all operations are required and a failure means the entire operation should fail. Promise.allSettled always resolves with an array of result objects {status, value/reason} for every input promise, regardless of success or failure. Use it when you want to attempt all operations and handle partial failures gracefully — like loading a dashboard where some widgets can fail without breaking the whole page.

Q: How would you implement Promise.all from scratch?

function promiseAll<T>(promises: Promise<T>[]): Promise<T[]> {
  return new Promise((resolve, reject) => {
    if (promises.length === 0) { resolve([]); return; }

    const results: T[] = new Array(promises.length);
    let remaining = promises.length;

    promises.forEach((p, i) => {
      Promise.resolve(p).then(value => {
        results[i] = value;
        if (--remaining === 0) resolve(results);
      }, reject);  // reject immediately on first failure
    });
  });
}

Key: index tracking preserves order (results[i] = value, not push), and the reject shortcut on any failure is passed directly.


Learning Resources