JavaScript Promises & Async/Await: The Complete Guide for 2026

Published February 11, 2026 · 35 min read

Asynchronous programming is the beating heart of JavaScript. Every network request, every file read, every timer, and every user interaction involves async operations. For years, callbacks were the only option — leading to deeply nested, hard-to-maintain code infamously known as "callback hell." Promises changed the game by giving async operations a proper data structure, and async/await took it further by making async code look and behave like synchronous code. This comprehensive guide covers everything you need to master asynchronous JavaScript: from foundational Promise mechanics to advanced patterns like async iterators, retry logic, and AbortController.

⚙ Try it live: Test any code example in this guide with our JavaScript Runner or the full JS Playground.

1. The Async Problem in JavaScript

JavaScript is single-threaded. It runs on one thread, in one call stack, processing one thing at a time. But the web is inherently asynchronous: you need to fetch data from APIs, read files, wait for user input, run timers, and handle events — all without freezing the UI.

The solution is the event loop. JavaScript delegates long-running operations (network requests, timers, I/O) to the browser or Node.js runtime, which handles them in the background. When the operation completes, a callback is placed in a queue, and the event loop picks it up when the call stack is empty.

// JavaScript is non-blocking by nature
console.log('1. Start');

setTimeout(() => {
  console.log('3. Timeout callback');
}, 0);  // Even with 0ms delay, this runs AFTER synchronous code

console.log('2. End');

// Output:
// 1. Start
// 2. End
// 3. Timeout callback

This non-blocking model is powerful, but it creates a fundamental challenge: how do you write code that depends on the result of an async operation? You cannot simply return a value from an async function because the value does not exist yet when the function returns. You need a way to say "when this finishes, do that."

// This DOES NOT work
function getUser(id) {
  let result;
  fetch(`/api/users/${id}`)
    .then(res => res.json())
    .then(user => {
      result = user;  // This runs LATER
    });
  return result;  // This returns undefined immediately
}

const user = getUser(1);
console.log(user);  // undefined -- the fetch hasn't completed yet

Three patterns have emerged to solve this problem, each building on the last: callbacks, promises, and async/await.

2. Callbacks and Callback Hell

The original solution to async programming in JavaScript was callbacks: pass a function that gets called when the operation completes.

// Basic callback pattern
function fetchUser(id, callback) {
  setTimeout(() => {
    // Simulating an API call
    const user = { id: id, name: 'Alice', email: 'alice@example.com' };
    callback(null, user);  // Convention: (error, result)
  }, 1000);
}

fetchUser(1, (err, user) => {
  if (err) {
    console.error('Failed:', err);
    return;
  }
  console.log(user.name);  // "Alice"
});

Callbacks work fine for simple cases. But when operations depend on each other, you end up nesting callbacks inside callbacks, creating the infamous "Pyramid of Doom" or callback hell:

// Callback hell: get user, then orders, then order details, then shipping
getUser(userId, (err, user) => {
  if (err) {
    handleError(err);
    return;
  }
  getOrders(user.id, (err, orders) => {
    if (err) {
      handleError(err);
      return;
    }
    getOrderDetails(orders[0].id, (err, details) => {
      if (err) {
        handleError(err);
        return;
      }
      getShippingStatus(details.trackingId, (err, status) => {
        if (err) {
          handleError(err);
          return;
        }
        console.log('Shipping status:', status);
        // Need to update the UI? Another callback...
        updateUI(status, (err) => {
          if (err) {
            handleError(err);
            return;
          }
          console.log('UI updated');
        });
      });
    });
  });
});

The problems with callbacks are severe:

  • Readability: Code grows rightward instead of downward, making it hard to follow the flow
  • Error handling: Every callback must check for errors individually; it is easy to forget one
  • Inversion of control: You hand your callback to another function and trust it will be called correctly (once, with the right arguments)
  • Composability: Combining, racing, or running callbacks in parallel requires manual coordination
  • No return values: Callbacks cannot return values to the caller; results are trapped inside the callback scope

Promises were designed to solve every one of these problems.

3. Promise Fundamentals

A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Think of it as a receipt for a future value: you place an order (start the async operation), get a receipt (the Promise), and later the order is fulfilled or rejected.

Promise States

A Promise is always in one of three states:

  • Pending: Initial state; the operation has not completed yet
  • Fulfilled: The operation completed successfully; the Promise has a value
  • Rejected: The operation failed; the Promise has a reason (error)

Once a Promise settles (fulfills or rejects), it never changes state again. A fulfilled Promise stays fulfilled forever with the same value. This immutability is a key design feature.

Creating Promises

// The Promise constructor takes an "executor" function
// The executor receives two callbacks: resolve and reject
const myPromise = new Promise((resolve, reject) => {
  // Do some async work...
  const success = true;

  if (success) {
    resolve('Operation succeeded!');  // Fulfill with a value
  } else {
    reject(new Error('Operation failed'));  // Reject with an error
  }
});

// Real example: wrapping setTimeout in a Promise
function delay(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

// Usage
delay(2000).then(() => console.log('2 seconds later'));

// Wrapping a callback-based API in a Promise
function readFilePromise(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, 'utf-8', (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

Consuming Promises with .then()

// .then() registers a callback to run when the Promise fulfills
const promise = new Promise((resolve) => {
  setTimeout(() => resolve(42), 1000);
});

promise.then(value => {
  console.log(value);  // 42 (after 1 second)
});

// .then() returns a NEW Promise, enabling chaining
promise
  .then(value => value * 2)     // 84
  .then(value => value + 10)    // 94
  .then(value => console.log(value));  // 94

// .then() takes two arguments: onFulfilled and onRejected
promise.then(
  value => console.log('Success:', value),
  error => console.error('Error:', error)
);

Shorthand: Promise.resolve() and Promise.reject()

// Create an already-fulfilled Promise
const resolved = Promise.resolve(42);
resolved.then(v => console.log(v));  // 42

// Create an already-rejected Promise
const rejected = Promise.reject(new Error('Failed'));
rejected.catch(err => console.error(err.message));  // "Failed"

// Useful for normalizing values that might or might not be Promises
function ensurePromise(value) {
  return Promise.resolve(value);
}

ensurePromise(42).then(v => console.log(v));           // 42
ensurePromise(Promise.resolve(42)).then(v => console.log(v)); // 42

// Promise.resolve() is also used as the start of a chain
Promise.resolve()
  .then(() => fetchUser(1))
  .then(user => fetchOrders(user.id))
  .then(orders => console.log(orders));

The Microtask Queue

// Promise callbacks run in the microtask queue,
// which has higher priority than the macrotask queue (setTimeout)

console.log('1. Synchronous');

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

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

// Output order:
// 1. Synchronous
// 2. Promise (microtask)
// 3. Promise (microtask)
// 4. setTimeout (macrotask)

// This matters because microtasks run BEFORE the browser renders
// and BEFORE macrotasks, even if the macrotask was queued first

4. Promise Chaining

The real power of Promises comes from chaining. Every .then(), .catch(), and .finally() returns a new Promise, creating a flat, readable pipeline of async operations.

.then() Chaining

// Compare: callback hell vs Promise chain
// BEFORE (callbacks):
getUser(userId, (err, user) => {
  getOrders(user.id, (err, orders) => {
    getOrderDetails(orders[0].id, (err, details) => {
      console.log(details);
    });
  });
});

// AFTER (Promises):
getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => console.log(details))
  .catch(err => console.error(err));

// Each .then() receives the return value of the previous one
fetch('/api/users/1')
  .then(response => {
    console.log('Status:', response.status);
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();  // Returns a Promise
  })
  .then(user => {
    console.log('User:', user.name);
    return fetch(`/api/users/${user.id}/posts`);
  })
  .then(response => response.json())
  .then(posts => {
    console.log(`Found ${posts.length} posts`);
    return posts;
  });

Returning Values vs Promises

// .then() automatically wraps non-Promise return values in a Promise
Promise.resolve(1)
  .then(v => v + 1)           // returns 2 (wrapped in Promise.resolve(2))
  .then(v => v * 3)           // returns 6
  .then(v => console.log(v)); // 6

// If you return a Promise, the chain waits for it to settle
Promise.resolve(1)
  .then(v => {
    return new Promise(resolve => {
      setTimeout(() => resolve(v + 1), 1000);
    });
  })
  .then(v => console.log(v));  // 2 (after 1 second)

// If you return nothing, the next .then() receives undefined
Promise.resolve(1)
  .then(v => {
    console.log(v);  // 1
    // No return statement
  })
  .then(v => console.log(v));  // undefined

.catch() for Error Handling

// .catch() is shorthand for .then(undefined, onRejected)
// It catches errors from ANY previous step in the chain

fetchUser(1)
  .then(user => fetchOrders(user.id))  // If this throws...
  .then(orders => processOrders(orders))  // ...this is skipped
  .catch(err => {
    // Catches errors from fetchUser, fetchOrders, OR processOrders
    console.error('Something failed:', err.message);
    return [];  // Provide a fallback value
  })
  .then(result => {
    // Continues with either the real result or the fallback
    console.log('Result:', result);
  });

// Error propagation: errors skip all .then() handlers
// until they hit a .catch()
Promise.reject(new Error('Initial error'))
  .then(v => console.log('Skip 1'))  // Skipped
  .then(v => console.log('Skip 2'))  // Skipped
  .then(v => console.log('Skip 3'))  // Skipped
  .catch(err => {
    console.error('Caught:', err.message);  // "Initial error"
    return 'recovered';
  })
  .then(v => console.log('Continue:', v));  // "Continue: recovered"

.finally() for Cleanup

// .finally() runs regardless of whether the Promise fulfilled or rejected
// It does NOT receive the value or error -- it's for cleanup only

let isLoading = true;

fetchData('/api/users')
  .then(data => {
    renderUsers(data);
  })
  .catch(err => {
    showError(err.message);
  })
  .finally(() => {
    isLoading = false;  // Always runs
    hideSpinner();      // Clean up UI state
  });

// .finally() passes through the value/error unchanged
Promise.resolve(42)
  .finally(() => console.log('Cleanup'))  // "Cleanup"
  .then(v => console.log(v));             // 42

Promise.reject(new Error('Oops'))
  .finally(() => console.log('Cleanup'))  // "Cleanup"
  .catch(err => console.error(err.message)); // "Oops"

Practical Pattern: Loading States

function loadUserProfile(userId) {
  showLoadingSpinner();

  return fetchUser(userId)
    .then(user => {
      updateHeader(user.name);
      return fetchUserPosts(user.id);
    })
    .then(posts => {
      renderPostList(posts);
      return fetchUserFriends(userId);
    })
    .then(friends => {
      renderFriendList(friends);
    })
    .catch(err => {
      showErrorBanner(`Failed to load profile: ${err.message}`);
    })
    .finally(() => {
      hideLoadingSpinner();
    });
}

5. Promise Static Methods

The Promise class provides four static methods for working with multiple Promises concurrently. Understanding when to use each one is essential for writing efficient async code.

Promise.all() — All Must Succeed

// Promise.all() takes an array of Promises and returns a single Promise
// that fulfills with an array of ALL results, or rejects if ANY fails

// Parallel API calls -- much faster than sequential
const [users, posts, comments] = await Promise.all([
  fetch('/api/users').then(r => r.json()),
  fetch('/api/posts').then(r => r.json()),
  fetch('/api/comments').then(r => r.json())
]);
// All three requests run simultaneously
// Total time = time of the SLOWEST request (not sum of all three)

console.log(`${users.length} users, ${posts.length} posts`);

// If any promise rejects, Promise.all rejects immediately
try {
  const results = await Promise.all([
    fetch('/api/valid-endpoint').then(r => r.json()),
    fetch('/api/broken-endpoint').then(r => {
      if (!r.ok) throw new Error('Endpoint broken');
      return r.json();
    }),
    fetch('/api/another-endpoint').then(r => r.json())
  ]);
} catch (err) {
  // Rejects with the FIRST error
  console.error('One request failed:', err.message);
}

// Practical: load all resources needed for a page
async function loadDashboard() {
  const [userData, analyticsData, notificationsData] = await Promise.all([
    fetchJSON('/api/user/me'),
    fetchJSON('/api/analytics/summary'),
    fetchJSON('/api/notifications?unread=true')
  ]);

  renderDashboard({ userData, analyticsData, notificationsData });
}

Promise.allSettled() — Get All Results

// Promise.allSettled() waits for ALL promises to settle (fulfill or reject)
// It NEVER rejects -- you always get results for every promise

const results = await Promise.allSettled([
  fetch('/api/users').then(r => r.json()),
  fetch('/api/broken-endpoint').then(r => r.json()),
  fetch('/api/posts').then(r => r.json())
]);

// Each result has a 'status' field: 'fulfilled' or 'rejected'
results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`Request ${index}: Success`, result.value);
  } else {
    console.log(`Request ${index}: Failed`, result.reason.message);
  }
});

// Practical: send notifications to multiple services
async function notifyAll(message) {
  const results = await Promise.allSettled([
    sendEmail(message),
    sendSlack(message),
    sendSMS(message),
    sendPushNotification(message)
  ]);

  const succeeded = results.filter(r => r.status === 'fulfilled').length;
  const failed = results.filter(r => r.status === 'rejected').length;
  console.log(`Notifications: ${succeeded} sent, ${failed} failed`);

  // Log failures for debugging
  results.forEach((result, i) => {
    if (result.status === 'rejected') {
      console.error(`Service ${i} failed:`, result.reason);
    }
  });
}

// Practical: batch processing where partial success is acceptable
async function processItems(items) {
  const results = await Promise.allSettled(
    items.map(item => processItem(item))
  );

  const successful = results
    .filter(r => r.status === 'fulfilled')
    .map(r => r.value);

  const errors = results
    .filter(r => r.status === 'rejected')
    .map(r => r.reason);

  return { successful, errors };
}

Promise.race() — First to Settle Wins

// Promise.race() resolves or rejects with the FIRST promise to settle

// Practical: implement a timeout
function fetchWithTimeout(url, timeoutMs = 5000) {
  return Promise.race([
    fetch(url),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
    )
  ]);
}

try {
  const response = await fetchWithTimeout('/api/slow-endpoint', 3000);
  const data = await response.json();
} catch (err) {
  if (err.message === 'Request timed out') {
    console.error('The server took too long to respond');
  }
}

// Practical: use the fastest mirror
async function fetchFromFastestMirror(path) {
  return Promise.race([
    fetch(`https://cdn1.example.com${path}`),
    fetch(`https://cdn2.example.com${path}`),
    fetch(`https://cdn3.example.com${path}`)
  ]);
}
// Uses whichever CDN responds first

// Important: the other promises still run to completion.
// Promise.race() does NOT cancel them.
// Use AbortController if you need cancellation (covered later).

Promise.any() — First to Succeed Wins

// Promise.any() resolves with the FIRST fulfilled promise
// Ignores rejections unless ALL promises reject

// Difference from race(): race() settles with the first to settle (fulfill OR reject)
// any() settles with the first to FULFILL (ignores rejections)

// Practical: try multiple API endpoints, use the first that works
async function fetchWithFallback() {
  try {
    const response = await Promise.any([
      fetch('https://primary-api.com/data'),
      fetch('https://backup-api.com/data'),
      fetch('https://tertiary-api.com/data')
    ]);
    return await response.json();
  } catch (err) {
    // AggregateError: ALL promises rejected
    console.error('All APIs failed:', err.errors);
    throw err;
  }
}

// AggregateError contains all rejection reasons
try {
  await Promise.any([
    Promise.reject(new Error('Server 1 down')),
    Promise.reject(new Error('Server 2 down')),
    Promise.reject(new Error('Server 3 down'))
  ]);
} catch (err) {
  console.log(err instanceof AggregateError);  // true
  console.log(err.errors.length);               // 3
  err.errors.forEach(e => console.log(e.message));
  // "Server 1 down"
  // "Server 2 down"
  // "Server 3 down"
}

// Practical: load asset from the fastest source
async function loadImage(sources) {
  const image = await Promise.any(
    sources.map(src =>
      new Promise((resolve, reject) => {
        const img = new Image();
        img.onload = () => resolve(img);
        img.onerror = reject;
        img.src = src;
      })
    )
  );
  document.body.appendChild(image);
}

Comparison Table

// Promise.all()        -- ALL must fulfill; rejects on FIRST rejection
// Promise.allSettled()  -- Waits for ALL; never rejects
// Promise.race()        -- FIRST to settle (fulfill or reject) wins
// Promise.any()         -- FIRST to fulfill wins; rejects only if ALL reject

// When to use each:
// all()         -> Load multiple required resources in parallel
// allSettled()  -> Batch operations where partial failure is OK
// race()        -> Timeouts, using the fastest response
// any()         -> Fallbacks, trying multiple sources

6. Async/Await Syntax

Introduced in ES2017, async/await is syntactic sugar built on top of Promises. It makes asynchronous code look and behave like synchronous code, dramatically improving readability without sacrificing any functionality.

The async Keyword

// 'async' before a function makes it return a Promise
async function greet() {
  return 'Hello, world!';
}

// Equivalent to:
function greet() {
  return Promise.resolve('Hello, world!');
}

greet().then(msg => console.log(msg));  // "Hello, world!"

// async works with all function forms
const arrowAsync = async () => {
  return 42;
};

const objMethod = {
  async fetchData() {
    return await fetch('/api/data');
  }
};

class ApiClient {
  async get(url) {
    const response = await fetch(url);
    return response.json();
  }
}

// An async function ALWAYS returns a Promise, even if you
// return a plain value -- it's automatically wrapped

The await Keyword

// 'await' pauses the async function until the Promise settles
// It can ONLY be used inside an async function (or at the top level of a module)

async function getUserProfile(userId) {
  // Each await pauses until the Promise resolves
  const response = await fetch(`/api/users/${userId}`);
  const user = await response.json();
  return user;
}

// Compare: Promise chain vs async/await
// Promise chain:
function getOrderDetails(userId) {
  return fetchUser(userId)
    .then(user => fetchOrders(user.id))
    .then(orders => fetchDetails(orders[0].id))
    .then(details => {
      console.log(details);
      return details;
    });
}

// async/await (much clearer):
async function getOrderDetails(userId) {
  const user = await fetchUser(userId);
  const orders = await fetchOrders(user.id);
  const details = await fetchDetails(orders[0].id);
  console.log(details);
  return details;
}

// await can be used with any "thenable" (object with a .then method)
// but is most commonly used with Promises

Top-Level Await

// In ES modules (files with .mjs extension or "type": "module" in package.json),
// you can use await at the top level without wrapping in an async function

// config.mjs
const response = await fetch('/config.json');
const config = await response.json();
export default config;

// main.mjs
import config from './config.mjs';
console.log(config.apiUrl);  // Config is already loaded

// This is especially useful for:
// - Loading configuration before the app starts
// - Database connections at module load time
// - Dynamic imports
const locale = navigator.language;
const messages = await import(`./locales/${locale}.mjs`);

Async/Await with Conditional Logic

// One of the biggest advantages: natural control flow
async function processOrder(orderId) {
  const order = await fetchOrder(orderId);

  // Simple if/else -- impossible to do cleanly with .then() chains
  if (order.status === 'pending') {
    const payment = await processPayment(order);

    if (payment.success) {
      await updateOrderStatus(orderId, 'paid');
      await sendConfirmationEmail(order.email);
      return { success: true, message: 'Order processed' };
    } else {
      await updateOrderStatus(orderId, 'payment-failed');
      return { success: false, message: 'Payment failed' };
    }
  } else if (order.status === 'paid') {
    return { success: true, message: 'Already paid' };
  } else {
    throw new Error(`Unexpected order status: ${order.status}`);
  }
}

// Loops work naturally too
async function processAllOrders(orderIds) {
  const results = [];

  for (const id of orderIds) {
    const result = await processOrder(id);  // Sequential
    results.push(result);
  }

  return results;
}

7. Error Handling

Proper error handling is the difference between a robust application and one that silently swallows failures. Both Promises and async/await provide powerful error handling mechanisms, but the patterns differ.

try/catch with async/await

// The most common pattern: wrap await calls in try/catch
async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);

    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
    }

    const user = await response.json();
    return user;
  } catch (err) {
    if (err.name === 'TypeError') {
      // Network error (no internet, DNS failure, etc.)
      console.error('Network error:', err.message);
    } else {
      console.error('Failed to fetch user:', err.message);
    }
    return null;  // Return a fallback
  }
}

// Catching specific errors from different operations
async function createOrder(userId, items) {
  try {
    const user = await fetchUser(userId);
    const inventory = await checkInventory(items);
    const order = await submitOrder(user, inventory);
    const confirmation = await sendConfirmation(order);
    return confirmation;
  } catch (err) {
    if (err instanceof AuthenticationError) {
      redirectToLogin();
    } else if (err instanceof InventoryError) {
      showOutOfStockMessage(err.items);
    } else if (err instanceof PaymentError) {
      showPaymentFailedMessage(err.reason);
    } else {
      showGenericError('Something went wrong. Please try again.');
    }
    throw err;  // Re-throw if the caller should know about it
  }
}

.catch() with Promises

// With Promise chains, use .catch() for error handling
fetchUser(userId)
  .then(user => fetchOrders(user.id))
  .then(orders => renderOrders(orders))
  .catch(err => {
    // Catches errors from ANY step above
    console.error('Pipeline failed:', err.message);
    renderErrorState();
  });

// You can place .catch() mid-chain for recovery
fetchUser(userId)
  .then(user => fetchAvatar(user.avatarUrl))
  .catch(err => {
    // If avatar fails, use a default -- but continue the chain
    console.warn('Avatar failed, using default');
    return '/images/default-avatar.png';
  })
  .then(avatarUrl => {
    // This runs regardless: either real avatar or default
    renderAvatar(avatarUrl);
  });

// Per-promise error handling with .catch()
async function loadDashboard() {
  const user = await fetchUser(1).catch(() => null);
  const posts = await fetchPosts().catch(() => []);
  const notifications = await fetchNotifications().catch(() => []);

  // Each failure is handled independently; one failure
  // does not prevent the others from loading
  renderDashboard({ user, posts, notifications });
}

The Go-Style Pattern

// A popular pattern: wrapper that returns [error, result] tuples
// Avoids deeply nested try/catch blocks

async function to(promise) {
  try {
    const result = await promise;
    return [null, result];
  } catch (err) {
    return [err, null];
  }
}

// Usage: clean error handling without try/catch
async function handleSignup(formData) {
  const [validationErr, validData] = await to(validateForm(formData));
  if (validationErr) {
    showValidationErrors(validationErr);
    return;
  }

  const [createErr, user] = await to(createUser(validData));
  if (createErr) {
    showError('Could not create account');
    return;
  }

  const [emailErr] = await to(sendWelcomeEmail(user.email));
  if (emailErr) {
    // Non-critical: log but do not block
    console.warn('Welcome email failed:', emailErr);
  }

  redirectToDashboard(user);
}

Unhandled Rejections

// NEVER leave a Promise rejection unhandled
// In Node.js, unhandled rejections crash the process (since Node 15)
// In browsers, they show as warnings

// BAD: no error handling
fetch('/api/data').then(r => r.json());  // If this fails, rejection is unhandled

// GOOD: always handle errors
fetch('/api/data')
  .then(r => r.json())
  .catch(err => console.error('Fetch failed:', err));

// Listen for unhandled rejections globally (safety net, not a substitute)
// Browser:
window.addEventListener('unhandledrejection', event => {
  console.error('Unhandled rejection:', event.reason);
  event.preventDefault();  // Prevent the default console error
});

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

// Common cause: forgetting to await or return a Promise
async function processData() {
  // BUG: not awaited, not returned -- errors are lost
  saveToDatabase(data);  // Missing await!

  // FIX:
  await saveToDatabase(data);
}

Custom Error Classes

// Create custom error types for different failure modes
class HttpError extends Error {
  constructor(status, statusText, url) {
    super(`HTTP ${status}: ${statusText} (${url})`);
    this.name = 'HttpError';
    this.status = status;
    this.statusText = statusText;
    this.url = url;
  }
}

class TimeoutError extends Error {
  constructor(ms) {
    super(`Operation timed out after ${ms}ms`);
    this.name = 'TimeoutError';
    this.timeout = ms;
  }
}

class ValidationError extends Error {
  constructor(field, message) {
    super(`Validation failed for ${field}: ${message}`);
    this.name = 'ValidationError';
    this.field = field;
  }
}

// Usage: clear, actionable error handling
async function fetchAPI(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new HttpError(response.status, response.statusText, url);
  }
  return response.json();
}

try {
  const data = await fetchAPI('/api/users');
} catch (err) {
  if (err instanceof HttpError && err.status === 404) {
    showNotFoundPage();
  } else if (err instanceof HttpError && err.status === 401) {
    redirectToLogin();
  } else {
    showGenericError(err.message);
  }
}

8. Real-World Patterns

These are the async patterns you will encounter and use daily in production JavaScript applications.

Sequential vs Parallel Execution

// SEQUENTIAL: each operation waits for the previous one
// Total time = sum of all operation times
async function sequential() {
  const user = await fetchUser(1);         // 500ms
  const posts = await fetchPosts(user.id);  // 300ms
  const comments = await fetchComments();   // 200ms
  // Total: ~1000ms
  return { user, posts, comments };
}

// PARALLEL: all operations start at the same time
// Total time = time of the slowest operation
async function parallel() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(1),       // 500ms
    fetchPosts(1),      // 300ms
    fetchComments()     // 200ms
  ]);
  // Total: ~500ms (3x faster!)
  return { user, posts, comments };
}

// MIXED: some sequential, some parallel
async function mixed() {
  // Step 1: Must get user first (everything depends on it)
  const user = await fetchUser(1);

  // Step 2: Posts and friends can load in parallel
  const [posts, friends] = await Promise.all([
    fetchPosts(user.id),
    fetchFriends(user.id)
  ]);

  return { user, posts, friends };
}

Retry Logic with Exponential Backoff

// Retry failed operations with increasing delay
async function fetchWithRetry(url, options = {}) {
  const {
    retries = 3,
    backoff = 1000,      // Initial delay: 1 second
    backoffFactor = 2,   // Double the delay each retry
    maxDelay = 30000,    // Cap at 30 seconds
    retryOn = [500, 502, 503, 504]  // Retry on these status codes
  } = options;

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url);

      if (!response.ok && retryOn.includes(response.status) && attempt < retries) {
        throw new Error(`HTTP ${response.status}`);
      }

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      return response;
    } catch (err) {
      if (attempt === retries) {
        throw err;  // Final attempt failed; give up
      }

      const delay = Math.min(backoff * Math.pow(backoffFactor, attempt), maxDelay);
      const jitter = delay * (0.5 + Math.random() * 0.5);  // Add randomness

      console.warn(
        `Attempt ${attempt + 1} failed: ${err.message}. ` +
        `Retrying in ${Math.round(jitter)}ms...`
      );

      await new Promise(resolve => setTimeout(resolve, jitter));
    }
  }
}

// Usage
const response = await fetchWithRetry('/api/unreliable-endpoint', {
  retries: 5,
  backoff: 500
});
const data = await response.json();

Timeout Pattern

// Wrap any Promise with a timeout
function withTimeout(promise, ms, message = 'Operation timed out') {
  const timeout = new Promise((_, reject) => {
    const id = setTimeout(() => {
      clearTimeout(id);
      reject(new Error(message));
    }, ms);
  });

  return Promise.race([promise, timeout]);
}

// Usage
try {
  const data = await withTimeout(
    fetch('/api/slow-endpoint').then(r => r.json()),
    5000,
    'API did not respond within 5 seconds'
  );
} catch (err) {
  if (err.message.includes('timed out')) {
    showTimeoutMessage();
  }
}

// Better: with AbortController for actual cancellation
function fetchWithTimeout(url, ms) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), ms);

  return fetch(url, { signal: controller.signal })
    .finally(() => clearTimeout(timeoutId));
}

Concurrency Limiter

// Process items in parallel but limit how many run at once
// Prevents overwhelming a server with too many simultaneous requests

async function mapWithConcurrency(items, fn, concurrency = 5) {
  const results = [];
  const executing = new Set();

  for (const [index, item] of items.entries()) {
    const promise = fn(item, index).then(result => {
      executing.delete(promise);
      return result;
    });

    executing.add(promise);
    results.push(promise);

    if (executing.size >= concurrency) {
      await Promise.race(executing);
    }
  }

  return Promise.all(results);
}

// Usage: upload 100 files, but max 5 at a time
const files = getFilesToUpload();  // Array of 100 files

const results = await mapWithConcurrency(
  files,
  async (file) => {
    const formData = new FormData();
    formData.append('file', file);
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData
    });
    return response.json();
  },
  5  // Max 5 concurrent uploads
);

console.log(`Uploaded ${results.length} files`);

Debounced Async Operations

// Debounce: only execute after the user stops triggering for N ms
// Critical for search-as-you-type features

function debounceAsync(fn, delay) {
  let timeoutId;
  let currentAbortController;

  return function (...args) {
    // Cancel the previous pending call
    clearTimeout(timeoutId);

    // Abort any in-flight request
    if (currentAbortController) {
      currentAbortController.abort();
    }

    return new Promise((resolve, reject) => {
      timeoutId = setTimeout(async () => {
        currentAbortController = new AbortController();
        try {
          const result = await fn(...args, currentAbortController.signal);
          resolve(result);
        } catch (err) {
          if (err.name !== 'AbortError') {
            reject(err);
          }
        }
      }, delay);
    });
  };
}

// Usage
const searchAPI = debounceAsync(async (query, signal) => {
  const response = await fetch(`/api/search?q=${query}`, { signal });
  return response.json();
}, 300);

// In an event handler:
searchInput.addEventListener('input', async (e) => {
  try {
    const results = await searchAPI(e.target.value);
    renderResults(results);
  } catch (err) {
    console.error('Search failed:', err);
  }
});

Promise Queue (FIFO)

// Process async tasks one at a time in order
class AsyncQueue {
  constructor() {
    this.queue = [];
    this.processing = false;
  }

  async add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this.process();
    });
  }

  async process() {
    if (this.processing) return;
    this.processing = true;

    while (this.queue.length > 0) {
      const { task, resolve, reject } = this.queue.shift();
      try {
        const result = await task();
        resolve(result);
      } catch (err) {
        reject(err);
      }
    }

    this.processing = false;
  }
}

// Usage: ensure database writes happen in order
const writeQueue = new AsyncQueue();

async function saveRecord(record) {
  return writeQueue.add(async () => {
    const response = await fetch('/api/records', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(record)
    });
    return response.json();
  });
}

// Multiple saves queued -- they execute one at a time
saveRecord({ name: 'First' });
saveRecord({ name: 'Second' });
saveRecord({ name: 'Third' });

9. Fetch API and Async Data Loading

The Fetch API is the modern standard for making HTTP requests in JavaScript. It returns Promises, making it the natural companion for async/await.

Basic Fetch Patterns

// GET request
async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);

  // fetch() only rejects on network errors, NOT HTTP errors
  // You must check response.ok or response.status
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  return response.json();  // Parses JSON body (returns a Promise)
}

// POST request
async function createUser(userData) {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(userData)
  });

  if (!response.ok) {
    const errorBody = await response.json().catch(() => ({}));
    throw new Error(errorBody.message || `HTTP ${response.status}`);
  }

  return response.json();
}

// PUT, PATCH, DELETE
async function updateUser(id, updates) {
  return fetch(`/api/users/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(updates)
  });
}

async function deleteUser(id) {
  const response = await fetch(`/api/users/${id}`, {
    method: 'DELETE'
  });

  if (!response.ok) {
    throw new Error(`Failed to delete user: HTTP ${response.status}`);
  }
}

Response Methods

// The Response object has multiple methods for reading the body
// You can only read the body ONCE -- it's a stream

const response = await fetch('/api/data');

// Read as JSON
const jsonData = await response.json();

// Read as plain text
const textData = await response.text();

// Read as binary (Blob)
const blobData = await response.blob();

// Read as ArrayBuffer (for low-level binary processing)
const bufferData = await response.arrayBuffer();

// Read as FormData (for multipart responses)
const formData = await response.formData();

// Response metadata
console.log(response.status);       // 200
console.log(response.statusText);   // "OK"
console.log(response.ok);           // true (status 200-299)
console.log(response.headers.get('Content-Type'));  // "application/json"
console.log(response.url);          // The final URL (after redirects)
console.log(response.redirected);   // true if request was redirected

File Uploads

// Upload a file with FormData
async function uploadFile(file) {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('description', 'My document');

  // Do NOT set Content-Type header -- the browser sets it
  // automatically with the correct multipart boundary
  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData
  });

  if (!response.ok) throw new Error('Upload failed');
  return response.json();
}

// Upload multiple files
async function uploadFiles(files) {
  const formData = new FormData();
  for (const file of files) {
    formData.append('files', file);
  }

  return fetch('/api/upload-multiple', {
    method: 'POST',
    body: formData
  });
}

// Upload with progress tracking (using XMLHttpRequest, as fetch
// does not natively support upload progress)
function uploadWithProgress(file, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('file', file);

    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        onProgress(Math.round((e.loaded / e.total) * 100));
      }
    });

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText));
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`));
      }
    });

    xhr.addEventListener('error', () => reject(new Error('Network error')));
    xhr.open('POST', '/api/upload');
    xhr.send(formData);
  });
}

A Reusable API Client

// Build a fetch wrapper for your application
class APIClient {
  constructor(baseURL, defaultHeaders = {}) {
    this.baseURL = baseURL;
    this.defaultHeaders = {
      'Content-Type': 'application/json',
      ...defaultHeaders
    };
  }

  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;
    const config = {
      ...options,
      headers: {
        ...this.defaultHeaders,
        ...options.headers
      }
    };

    if (config.body && typeof config.body === 'object') {
      config.body = JSON.stringify(config.body);
    }

    const response = await fetch(url, config);

    if (!response.ok) {
      const error = await response.json().catch(() => ({
        message: response.statusText
      }));
      throw new HttpError(response.status, error.message, url);
    }

    // Handle 204 No Content
    if (response.status === 204) return null;

    return response.json();
  }

  get(endpoint, options) {
    return this.request(endpoint, { ...options, method: 'GET' });
  }

  post(endpoint, body, options) {
    return this.request(endpoint, { ...options, method: 'POST', body });
  }

  put(endpoint, body, options) {
    return this.request(endpoint, { ...options, method: 'PUT', body });
  }

  patch(endpoint, body, options) {
    return this.request(endpoint, { ...options, method: 'PATCH', body });
  }

  delete(endpoint, options) {
    return this.request(endpoint, { ...options, method: 'DELETE' });
  }
}

// Usage
const api = new APIClient('https://api.example.com', {
  Authorization: 'Bearer your-token-here'
});

const users = await api.get('/users');
const newUser = await api.post('/users', { name: 'Alice', email: 'alice@test.com' });
await api.patch(`/users/${newUser.id}`, { name: 'Alice Smith' });
await api.delete(`/users/${newUser.id}`);

10. Common Mistakes

Even experienced developers make these async mistakes. Learning to spot them will save you hours of debugging.

Mistake 1: Forgetting to await

// The most common async bug: forgetting 'await'
async function saveData(data) {
  // BUG: not awaited -- fires and forgets
  fetch('/api/save', {
    method: 'POST',
    body: JSON.stringify(data)
  });
  // The function returns before the save completes
  // If the save fails, the error is SILENTLY LOST
  console.log('Saved!');  // Lies! It may not be saved yet
}

// FIX: always await async operations
async function saveData(data) {
  await fetch('/api/save', {
    method: 'POST',
    body: JSON.stringify(data)
  });
  console.log('Saved!');  // Now this is true
}

// Another subtle version: forgetting to await in a conditional
async function maybeDelete(id, shouldDelete) {
  if (shouldDelete) {
    deleteItem(id);  // BUG: missing await
  }
  console.log('Done');  // Runs before deleteItem finishes
}

Mistake 2: Sequential When Parallel is Possible

// BAD: These requests have no dependency on each other
// but they run one after another (wasting time)
async function loadDashboard() {
  const users = await fetchUsers();       // 500ms
  const posts = await fetchPosts();       // 300ms
  const analytics = await fetchAnalytics(); // 400ms
  // Total: ~1200ms
}

// GOOD: Run independent operations in parallel
async function loadDashboard() {
  const [users, posts, analytics] = await Promise.all([
    fetchUsers(),       // 500ms
    fetchPosts(),       // 300ms
    fetchAnalytics()    // 400ms
  ]);
  // Total: ~500ms (the slowest one)
}

// WATCH OUT: await in a loop is almost always wrong
// BAD: sequential loop
async function processItems(items) {
  const results = [];
  for (const item of items) {
    const result = await processItem(item);  // One at a time!
    results.push(result);
  }
  return results;
}

// GOOD: parallel processing
async function processItems(items) {
  return Promise.all(items.map(item => processItem(item)));
}

// GOOD: parallel with concurrency limit (from earlier pattern)
async function processItems(items) {
  return mapWithConcurrency(items, processItem, 10);
}

Mistake 3: Not Handling fetch HTTP Errors

// BIG GOTCHA: fetch() does NOT reject on HTTP errors!
// It only rejects on network failures (no internet, DNS failure, etc.)

// BAD: assumes fetch rejects on 404/500
try {
  const data = await fetch('/api/nonexistent');
  const json = await data.json();  // Will try to parse the 404 error page
} catch (err) {
  // This only catches NETWORK errors, not HTTP 404/500
}

// GOOD: always check response.ok
async function fetchJSON(url) {
  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  return response.json();
}

try {
  const data = await fetchJSON('/api/nonexistent');
} catch (err) {
  console.error(err.message);  // "HTTP 404: Not Found"
}

Mistake 4: Creating Promises Inside Promises

// BAD: Promise constructor anti-pattern
// Wrapping an existing Promise in a new Promise is unnecessary
function getUser(id) {
  return new Promise((resolve, reject) => {
    fetch(`/api/users/${id}`)
      .then(response => response.json())
      .then(user => resolve(user))
      .catch(err => reject(err));
  });
}

// GOOD: Just return the Promise chain directly
function getUser(id) {
  return fetch(`/api/users/${id}`)
    .then(response => response.json());
}

// BEST: Use async/await
async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// The ONLY time you need new Promise() is when wrapping
// a callback-based API that does not already return a Promise
function readFileAsync(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, 'utf-8', (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

Mistake 5: Returning Inside .then() Without Chaining

// BAD: value is lost because return is inside the callback
function getUserName(id) {
  fetch(`/api/users/${id}`)
    .then(r => r.json())
    .then(user => {
      return user.name;  // This return goes nowhere useful
    });
  // The function returns undefined
}

// GOOD: return the entire chain
function getUserName(id) {
  return fetch(`/api/users/${id}`)
    .then(r => r.json())
    .then(user => user.name);
}

// BEST: use async/await
async function getUserName(id) {
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();
  return user.name;
}

Mistake 6: Mixing .then() and await Unnecessarily

// BAD: mixing styles creates confusion
async function fetchData() {
  const response = await fetch('/api/data');
  return response.json().then(data => {
    // Why use .then() when we have async/await?
    return data.items;
  });
}

// GOOD: pick one style and stick with it
async function fetchData() {
  const response = await fetch('/api/data');
  const data = await response.json();
  return data.items;
}

// Exception: .catch() on individual promises is sometimes cleaner
async function loadWithFallbacks() {
  // This is idiomatic: per-promise .catch() with await
  const user = await fetchUser(1).catch(() => defaultUser);
  const theme = await fetchTheme(user.id).catch(() => 'dark');
  return { user, theme };
}

11. Advanced Patterns

These patterns go beyond the basics and handle complex real-world scenarios: streaming data, cancellation, and asynchronous iteration.

AbortController: Canceling Async Operations

// AbortController lets you cancel fetch requests and other async operations

// Basic usage
const controller = new AbortController();
const { signal } = controller;

// Pass the signal to fetch
fetch('/api/data', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('Request was cancelled');
    } else {
      console.error('Fetch failed:', err);
    }
  });

// Cancel the request at any time
controller.abort();

// Practical: cancel previous request when a new one starts
// (prevents race conditions in search-as-you-type)
let currentController = null;

async function search(query) {
  // Cancel the previous in-flight request
  if (currentController) {
    currentController.abort();
  }

  currentController = new AbortController();

  try {
    const response = await fetch(
      `/api/search?q=${encodeURIComponent(query)}`,
      { signal: currentController.signal }
    );
    const results = await response.json();
    renderResults(results);
  } catch (err) {
    if (err.name !== 'AbortError') {
      showError(err.message);
    }
    // AbortError is expected -- ignore it
  }
}

// Called on every keystroke
searchInput.addEventListener('input', e => search(e.target.value));

// Practical: cancel on component unmount (React-style)
function useData(url) {
  useEffect(() => {
    const controller = new AbortController();

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

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

// AbortController with timeout
function fetchWithTimeout(url, timeoutMs) {
  const controller = new AbortController();

  // Abort after timeout
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  return fetch(url, { signal: controller.signal })
    .finally(() => clearTimeout(timeoutId));
}

Async Iterators and for-await-of

// Async iterators produce values asynchronously, one at a time
// Perfect for streaming data, paginated APIs, and event streams

// Creating an async iterable
const asyncIterable = {
  [Symbol.asyncIterator]() {
    let i = 0;
    return {
      async next() {
        if (i >= 3) return { done: true };
        await new Promise(resolve => setTimeout(resolve, 1000));
        return { value: i++, done: false };
      }
    };
  }
};

// Consuming with for-await-of
for await (const value of asyncIterable) {
  console.log(value);  // 0, 1, 2 (one per second)
}

// Practical: paginated API fetching
async function* fetchAllPages(baseUrl) {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const response = await fetch(`${baseUrl}?page=${page}&limit=100`);
    const data = await response.json();

    for (const item of data.results) {
      yield item;  // Yield one item at a time
    }

    hasMore = data.results.length === 100;
    page++;
  }
}

// Usage: process items as they arrive
for await (const user of fetchAllPages('/api/users')) {
  console.log(user.name);
  // Can break early -- remaining pages are never fetched
  if (user.name === 'target') break;
}

// Collect all items (if you need them all)
async function getAllUsers() {
  const users = [];
  for await (const user of fetchAllPages('/api/users')) {
    users.push(user);
  }
  return users;
}

Async Generators

// Async generators combine generators and async/await
// Declared with async function* and use both yield and await

// Stream processing: transform data as it flows through
async function* filterUsers(source, predicate) {
  for await (const user of source) {
    if (predicate(user)) {
      yield user;
    }
  }
}

async function* mapUsers(source, transform) {
  for await (const user of source) {
    yield transform(user);
  }
}

async function* take(source, count) {
  let taken = 0;
  for await (const item of source) {
    yield item;
    if (++taken >= count) return;
  }
}

// Compose: pipeline of async transformations
const allUsers = fetchAllPages('/api/users');
const activeUsers = filterUsers(allUsers, u => u.active);
const userNames = mapUsers(activeUsers, u => u.name);
const firstTen = take(userNames, 10);

for await (const name of firstTen) {
  console.log(name);
}

// Practical: WebSocket message stream
async function* websocketMessages(url) {
  const ws = new WebSocket(url);

  const messageQueue = [];
  let resolve;
  let waitForMessage = new Promise(r => resolve = r);

  ws.onmessage = (event) => {
    messageQueue.push(JSON.parse(event.data));
    resolve();
    waitForMessage = new Promise(r => resolve = r);
  };

  ws.onerror = (err) => {
    throw err;
  };

  try {
    while (ws.readyState === WebSocket.OPEN ||
           ws.readyState === WebSocket.CONNECTING) {
      if (messageQueue.length > 0) {
        yield messageQueue.shift();
      } else {
        await waitForMessage;
      }
    }
  } finally {
    ws.close();
  }
}

// Usage
for await (const message of websocketMessages('wss://api.example.com/ws')) {
  handleMessage(message);
}

Streaming Response Bodies

// Read a response body as a stream (useful for large files, progress)
async function fetchWithProgress(url, onProgress) {
  const response = await fetch(url);

  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const contentLength = parseInt(response.headers.get('Content-Length') || '0');
  const reader = response.body.getReader();
  const chunks = [];
  let receivedLength = 0;

  while (true) {
    const { done, value } = await reader.read();

    if (done) break;

    chunks.push(value);
    receivedLength += value.length;

    if (contentLength > 0 && onProgress) {
      onProgress(Math.round((receivedLength / contentLength) * 100));
    }
  }

  // Combine chunks into a single Uint8Array
  const allChunks = new Uint8Array(receivedLength);
  let position = 0;
  for (const chunk of chunks) {
    allChunks.set(chunk, position);
    position += chunk.length;
  }

  // Decode as text
  return new TextDecoder('utf-8').decode(allChunks);
}

// Usage with progress bar
const data = await fetchWithProgress('/api/large-dataset', (percent) => {
  progressBar.style.width = `${percent}%`;
  progressBar.textContent = `${percent}%`;
});

// Stream JSON Lines (newline-delimited JSON)
async function* streamJSONLines(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop();  // Keep incomplete line in buffer

    for (const line of lines) {
      if (line.trim()) {
        yield JSON.parse(line);
      }
    }
  }

  // Process remaining buffer
  if (buffer.trim()) {
    yield JSON.parse(buffer);
  }
}

for await (const record of streamJSONLines('/api/stream')) {
  processRecord(record);
}

Mutex / Lock Pattern

// Ensure only one instance of an async operation runs at a time
class AsyncMutex {
  constructor() {
    this.locked = false;
    this.queue = [];
  }

  async acquire() {
    if (!this.locked) {
      this.locked = true;
      return;
    }

    return new Promise(resolve => {
      this.queue.push(resolve);
    });
  }

  release() {
    if (this.queue.length > 0) {
      const next = this.queue.shift();
      next();  // Give the lock to the next waiter
    } else {
      this.locked = false;
    }
  }

  async runExclusive(fn) {
    await this.acquire();
    try {
      return await fn();
    } finally {
      this.release();
    }
  }
}

// Usage: prevent concurrent writes to a shared resource
const writeMutex = new AsyncMutex();

async function safeWrite(data) {
  return writeMutex.runExclusive(async () => {
    const current = await readFile('data.json');
    const updated = { ...JSON.parse(current), ...data };
    await writeFile('data.json', JSON.stringify(updated));
  });
}

// Multiple concurrent calls are serialized automatically
safeWrite({ name: 'Alice' });  // Runs first
safeWrite({ age: 30 });        // Waits, then runs second
safeWrite({ role: 'admin' });  // Waits, then runs third

Frequently Asked Questions

What is the difference between Promises and async/await in JavaScript?

Promises and async/await solve the same problem: managing asynchronous operations. A Promise is an object representing the eventual completion or failure of an async operation. async/await is syntactic sugar built on top of Promises that makes asynchronous code look and behave like synchronous code. An async function always returns a Promise, and await pauses execution until that Promise settles. Use async/await for readability in most cases, but understand Promises because methods like Promise.all() and Promise.race() are still essential.

When should I use Promise.all vs Promise.allSettled?

Use Promise.all() when all promises must succeed for the operation to be meaningful, such as loading all required resources for a page. If any promise rejects, Promise.all() immediately rejects with that error. Use Promise.allSettled() when you want results from all promises regardless of whether some fail, such as sending notifications to multiple services where partial success is acceptable. Promise.allSettled() never rejects and returns an array of objects with status 'fulfilled' or 'rejected' for each promise.

How do I handle errors with async/await?

The primary pattern is try/catch blocks: wrap your await calls in try and handle errors in catch. For more granular control, you can attach .catch() to individual promises before awaiting them. A popular alternative is the Go-style pattern where a wrapper function returns [error, result] tuples, avoiding nested try/catch. Always handle promise rejections; unhandled rejections will terminate Node.js processes and show warnings in browsers.

How do I run async operations in parallel vs sequentially?

For parallel execution, create all promises first then await them together with Promise.all(). For example: const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()]). For sequential execution, await each promise one after another: const users = await fetchUsers(); const posts = await fetchPosts();. A common mistake is using await inside a for loop when the operations are independent, which runs them sequentially instead of in parallel and wastes time.

What is an AbortController and how do I use it with fetch?

AbortController is a built-in API that lets you cancel fetch requests and other async operations. Create a controller with new AbortController(), pass controller.signal to your fetch options, and call controller.abort() to cancel the request. The fetch promise will reject with an AbortError. This is essential for preventing race conditions in UIs (e.g., canceling stale search requests), implementing timeouts, and cleaning up when components unmount in frameworks like React.

Conclusion

Asynchronous programming is the foundation of everything JavaScript does on the web and server. Promises gave us a proper abstraction for async operations — replacing callback hell with chainable, composable, and catchable workflows. Async/await took it a step further by making async code read like synchronous code, with natural control flow, error handling, and debugging.

If you take away three things from this guide, let them be: (1) always handle errors with try/catch or .catch() — unhandled rejections are bugs, (2) use Promise.all() for independent operations to get massive performance wins over sequential await, and (3) use AbortController to cancel stale requests and prevent race conditions. These three patterns alone will make your async code dramatically more robust and performant.

⚙ Practice: Try every example in this guide using our JavaScript Runner, or explore the full JS Playground for a complete coding environment.

Related Resources

Keep learning: Promises and async/await build on modern JavaScript fundamentals. Review our ES6+ Features Guide for the language features that underpin async patterns, and use our JavaScript Runner to experiment with the code examples above.

Related Resources

JavaScript Runner
Run and test JavaScript code instantly
JS Playground
Full-featured JavaScript coding environment
JavaScript ES6+ Features Guide
Master modern JavaScript fundamentals
JavaScript Array Methods Guide
Every array method with practical examples
JavaScript Array Methods Cheat Sheet
All array methods at a glance