Mastering Async JavaScript

Chapter 3: Mastering Async JavaScript

Asynchronous programming is the backbone of high-performance web applications. This chapter covers the Promise Lifecycle, the Event Loop, and advanced orchestration patterns for building resilient, non-blocking interfaces.


I. The Promise Interface

A Promise represents the eventual completion (or failure) of an asynchronous operation and its resulting value.

1. The Promise Lifecycle

A Promise is always in one of three mutually exclusive states:

PendingFulfilledRejectedSettled

  • Pending: Initial state, neither fulfilled nor rejected.
  • Fulfilled: The operation completed successfully.
  • Rejected: The operation failed.
  • Settled: A terminal state (either Fulfilled or Rejected). Once settled, a Promise's state and value are immutable.

2. Implementing a Promise-Returning Function

To wrap a non-promise API (like setTimeout or a legacy callback), use the Promise constructor.

Technical Reference: new Promise(executor)

  • Parameters: executor (Function) - A function receiving resolve and reject arguments.
  • Return Value: A new Promise instance.
/**
 * Example: A Promisified Delay Function
 * @param {number} ms - Milliseconds to wait
 * @returns {Promise<string>} - Resolves with a success message
 */
const wait = (ms) => {
  return new Promise((resolve, reject) => {
    // 1. Validation
    if (typeof ms !== 'number' || ms < 0) {
      return reject(new TypeError('ms must be a non-negative number'));
    }

    // 2. Asynchronous Logic
    setTimeout(() => {
      resolve(`Waited ${ms}ms`);
    }, ms);
  });
};

// Usage
wait(1000)
  .then(msg => console.log(msg))
  .catch(err => console.error(err.message));

II. The Event Loop & Microtasks

JavaScript's concurrency model is based on a single-threaded event loop. Understanding the priority of task queues is critical for performance.

1. Task Queue Priority

  1. Call Stack: Executes synchronous code immediately.
  2. Microtask Queue: Executed immediately after the current task and before the next Macrotask.
    • Sources: Promise.then/catch/finally, MutationObserver, queueMicrotask().
  3. Macrotask Queue: Executed one-by-one in subsequent loops.
    • Sources: setTimeout, setInterval, setImmediate, I/O, UI rendering.

2. Practical Example: Priority Execution

console.log('1. Sync'); // Call Stack

setTimeout(() => {
  console.log('4. Macrotask'); // Macrotask Queue
}, 0);

Promise.resolve().then(() => {
  console.log('3. Microtask'); // Microtask Queue
});

console.log('2. Sync'); // Call Stack

// Output: 1, 2, 3, 4

III. Advanced Promise Orchestration

When managing multiple asynchronous operations, use static Promise methods to control flow and error handling.

1. Static Method Reference

MethodBehaviorSuccess ConditionFailure Condition
Promise.all([p1, p2])Parallel execution.All fulfill.Any one rejects.
Promise.allSettled([p1, p2])Parallel execution.Always fulfills when all settle.Never rejects.
Promise.any([p1, p2])First success.First one to fulfill.All reject.
Promise.race([p1, p2])First settlement.First one to settle (Success).First one to settle (Error).

3. Practical Patterns: Handling Multiple Promises

When dealing with arrays of promises, choosing the right static method is critical for both performance and error resilience.

Pattern A: Atomic Batching with Promise.all

Context: Use this when your application requires multiple pieces of data that are functionally interdependent. If you are loading a dashboard where the "User Profile," "Account Settings," and "Recent Transactions" must all be present for a consistent UI state, Promise.all is the correct tool.

Mechanism:

  • Input: An iterable (usually an array) of promises.
  • Execution: All promises are initiated in parallel. The JavaScript engine moves them to the Macrotask or Microtask queue immediately.
  • Fail-Fast Logic: If any single promise in the array rejects, the entire Promise.all call rejects immediately with that error. This prevents the application from entering an "inconsistent state" where only half of the required data is available.
  • Note on Side Effects: Even if Promise.all rejects early, the other promises in the array continue to execute until they settle. JavaScript cannot "cancel" a promise once it has started (unless using an AbortController).
const loadDashboard = async (userId) => {
  try {
    const [profile, settings, stats] = await Promise.all([
      fetchProfile(userId),
      fetchSettings(userId),
      fetchStats(userId)
    ]);
    return { profile, settings, stats };
  } catch (err) {
    console.error('Critical failure: One or more dashboard components failed.', err);
    throw err; // Re-throw to handle at the UI layer
  }
};

Pattern B: Resilient Processing with Promise.allSettled

Context: Use this when you are performing a batch of independent operations where the success of one does not depend on the success of another. A classic example is a bulk file uploader or sending a notification to multiple recipients.

Mechanism:

  • Outcome: This method never rejects. It waits for every promise in the iterable to either fulfill or reject.
  • Return Value: An array of objects, each containing a status property ("fulfilled" or "rejected") and either a value or a reason.
  • Architectural Benefit: It allows you to provide granular feedback to the user (e.g., "4 files uploaded, 1 failed due to size limits") rather than a generic "Upload Failed" error.
const uploadFiles = async (files) => {
  const uploadPromises = files.map(file => uploadToServer(file));
  const results = await Promise.allSettled(uploadPromises);
  
  // High-fidelity result processing
  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      console.log(`File ${index} uploaded:`, result.value);
    } else {
      console.error(`File ${index} failed:`, result.reason);
    }
  });
};

Pattern C: Redundant Sourcing with Promise.any

Context: Use this for high-availability systems where you need the first successful response from multiple redundant sources. This is common when querying different database mirrors or trying multiple CDN endpoints for the same asset.

Mechanism:

  • First Success Wins: As soon as one promise fulfills, Promise.any resolves with that value and ignores all other subsequent fulfillments or rejections.
  • Ignoring Rejections: Unlike Promise.race, Promise.any will ignore errors from mirrors as long as at least one mirror eventually succeeds.
  • Terminal Failure: If all promises in the array reject, it throws an AggregateError—a special error object that contains an errors array of all individual rejection reasons.
const fetchFromMirrors = async (url) => {
  try {
    const data = await Promise.any([
      fetch(`https://mirror-us.com${url}`),
      fetch(`https://mirror-eu.com${url}`),
      fetch(`https://mirror-as.com${url}`)
    ]);
    return await data.json();
  } catch (err) {
    // If all three regions are down
    console.error('Service globally unavailable', err.errors);
  }
};

Pattern D: Competitive Execution with Promise.race

Context: Use this when you want to set a "time limit" on an operation or when you want to respond to the first settlement (success or failure). The most common implementation is a Network Timeout.

Mechanism:

  • Settlement Sprints: It resolves or rejects as soon as the very first promise in the iterable settles.
  • The Timeout Pattern: By racing a fetch against a setTimeout that rejects after 5 seconds, you can ensure your application doesn't hang indefinitely on a slow connection.
const fetchWithTimeout = async (url, limit = 5000) => {
  const timeout = new Promise((_, reject) => 
    setTimeout(() => reject(new Error(`Timeout: Request exceeded ${limit}ms`)), limit)
  );

  try {
    // The first one to settle (the fetch or the error) wins the race
    return await Promise.race([
      fetch(url),
      timeout
    ]);
  } catch (err) {
    console.warn('Race lost:', err.message);
  }
};

IV. Control Flow: Sequential vs. Parallel

A common performance pitfall is creating "async waterfalls" by awaiting independent operations in sequence.

1. The Waterfall Anti-Pattern (Slow)

// Total time: sum of all requests
const user = await fetchUser();
const posts = await fetchPosts(); // Starts only after fetchUser finishes
const friends = await fetchFriends(); // Starts only after fetchPosts finishes

2. The Parallel Pattern (Fast)

// Total time: time of the longest request
const [user, posts, friends] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchFriends()
]);

V. Core Engineering Standards

1. Error Handling Mandates

  • Always Catch: Every Promise chain must terminate with a .catch() or be wrapped in try/catch.
  • Global Monitoring: Listen for unhandledrejection to track swallowed errors.
    window.addEventListener('unhandledrejection', (e) => {
      console.error('Unhandled Promise Rejection:', e.reason);
    });
    

2. Performance Mandates

  • Microtask Starvation: Do not run CPU-intensive loops inside Microtasks (e.g., in a .then() block). This prevents the browser from rendering and processing Macrotasks (like clicks).
  • Memory Management: When creating Object URLs or event listeners inside async callbacks, ensure they are cleaned up in a .finally() block.

Async Loop: [OK ]