Skip to main content
JavaScript intermediate Lesson 16 of 24

Asynchronous JavaScript: Callbacks, Promises, and Async/Await

Master async JavaScript by understanding callbacks, Promises, and async/await — including error handling, parallel execution, and the full Promise API.

Asynchronous programming is at the core of JavaScript. Network requests, file reads, timers, and user interactions are all async. JavaScript has evolved three styles for handling this: callbacks, Promises, and async/await. Each style builds on the previous — understanding why each was introduced makes the progression logical rather than arbitrary.

The Callback Era

Callbacks are the oldest pattern. You pass a function that will be called when the async work finishes. This works well for simple one-step operations, but it was the primary model before Promises existed and it still appears in many APIs today (Node.js filesystem, setTimeout, event listeners).

function fetchUser(id, callback) {
  setTimeout(() => {
    if (id <= 0) {
      callback(new Error("Invalid ID"), null);
    } else {
      callback(null, { id, name: "Alice" });
    }
  }, 200);
}

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

This works, but nesting callbacks creates the infamous callback hell — each async step must be indented inside the previous one, making error handling repetitive and the logic difficult to follow:

// Callback hell — each step nested inside the previous
getUser(userId, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getInvoice(orders[0].id, (err, invoice) => {
      if (err) return handleError(err);
      renderPage(user, orders, invoice); // finally...
    });
  });
});

Promises

Promises were introduced to solve callback hell. A Promise represents an eventual value — it is in one of three states: pending, fulfilled, or rejected. Once settled, it never changes state. The key benefit is that you can chain .then() calls in a flat, sequential structure rather than nesting.

function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id <= 0) {
        reject(new Error("Invalid ID"));
      } else {
        resolve({ id, name: "Alice" });
      }
    }, 200);
  });
}

fetchUser(1)
  .then(user => console.log("Got user:", user.name))
  .catch(err => console.error("Failed:", err.message))
  .finally(() => console.log("Request complete")); // always runs, fulfilled or rejected

Promise Chaining

.then() returns a new Promise, enabling flat chains instead of nested callbacks. Any value returned from .then() is automatically wrapped in a resolved Promise, and a thrown error short-circuits to the nearest .catch().

// Same three-step flow as callback hell — now flat and readable
getUser(userId)
  .then(user => getOrders(user.id))         // returns a Promise; chain waits for it
  .then(orders => getInvoice(orders[0].id)) // same
  .then(invoice => renderPage(invoice))
  .catch(err => handleError(err));          // catches any error in the entire chain

Async/Await

async/await is syntax sugar over Promises, introduced in ES2017. It lets you write async code that reads like synchronous code, which eliminates the cognitive overhead of following .then() chains. Under the hood, every async function returns a Promise, and await simply pauses the function until the awaited Promise settles.

async function loadPage(userId) {
  try {
    const user    = await getUser(userId);        // pauses here until resolved
    const orders  = await getOrders(user.id);     // then here
    const invoice = await getInvoice(orders[0].id);
    renderPage(user, orders, invoice);
  } catch (err) {
    // catches rejections from any of the three awaits above
    handleError(err);
  }
}

Error Handling with Await

Because async/await looks synchronous, you handle errors with the same try/catch blocks you use for synchronous code. One important nuance: fetch only rejects on network failures — you must manually check response.ok for HTTP error status codes.

async function saveUser(data) {
  try {
    const response = await fetch("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });

    // fetch resolves even for 4xx/5xx — check ok explicitly
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    return await response.json();
  } catch (err) {
    console.error("saveUser failed:", err.message);
    throw err; // re-throw so the caller can handle it too
  }
}

Parallel Execution with Promise.all

A common mistake is awaiting independent async operations sequentially — each one waits for the previous to finish even though they have no dependency on each other. Promise.all runs them in parallel and waits for all to complete, reducing total wait time from the sum of all durations to roughly the duration of the slowest one.

// SLOW: 300ms total — each await blocks the next
async function slowLoad() {
  const user    = await fetchUser(1);    // 100ms
  const posts   = await fetchPosts(1);   // 100ms
  const friends = await fetchFriends(1); // 100ms
  return { user, posts, friends };
}

// FAST: ~100ms total — all three start simultaneously
async function fastLoad() {
  const [user, posts, friends] = await Promise.all([
    fetchUser(1),
    fetchPosts(1),
    fetchFriends(1),
  ]);
  return { user, posts, friends };
}

Promise.all rejects immediately if any promise rejects. Use Promise.allSettled when you want all results regardless of individual failures — for example, when loading optional dashboard widgets where a single failure shouldn’t blank the whole page:

async function loadDashboard(userId) {
  const results = await Promise.allSettled([
    fetchProfile(userId),
    fetchNotifications(userId),
    fetchRecommendations(userId),
  ]);

  return results.map(result => {
    if (result.status === "fulfilled") return result.value;
    console.warn("Widget failed to load:", result.reason.message);
    return null; // render a fallback widget instead of crashing
  });
}

The Full Promise API

The Promise API provides several combinators for different coordination patterns. Understanding when to reach for each one prevents both over-sequential and over-parallel code.

// Promise.race — resolves/rejects with the first settled promise
// Useful for timeouts: race the real request against a timer
async function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}

const data = await withTimeout(fetchHeavyData(), 5000);

// Promise.any — resolves with the first fulfilled promise
// Useful for redundancy: try multiple servers and use the fastest
const fastestServer = await Promise.any([
  fetch("https://server-a.example.com/ping"),
  fetch("https://server-b.example.com/ping"),
  fetch("https://server-c.example.com/ping"),
]);
// Rejects only if ALL promises reject (AggregateError)

// Promise.resolve / Promise.reject — create already-settled promises
// Useful for testing, or normalizing sync/async return values
const cached = Promise.resolve({ id: 1, name: "Alice" }); // immediately fulfilled
const failed = Promise.reject(new Error("not found"));      // immediately rejected

Common Mistakes

Running async in a forEach — the loop doesn’t wait for the callbacks to finish, so subsequent code runs before the async work is done:

// BUG: forEach ignores the returned Promises — no waiting occurs
userIds.forEach(async (id) => {
  const user = await fetchUser(id);
  await saveUser(user); // these run concurrently but forEach can't await them
});

// FIX 1: sequential processing — use for...of, which respects await
for (const id of userIds) {
  const user = await fetchUser(id);
  await saveUser(user);
}

// FIX 2: parallel processing — map returns Promises, then await all of them
await Promise.all(userIds.map(async (id) => {
  const user = await fetchUser(id);
  await saveUser(user);
}));

Key Takeaways

  • Callbacks are simple but nest poorly. Promises flatten the chain.
  • async/await is the modern standard — it reads like synchronous code.
  • Always handle rejections: .catch() or try/catch with await.
  • Use Promise.all for parallel independent work; Promise.allSettled when partial failure is acceptable.
  • fetch only rejects on network errors — always check response.ok for HTTP errors.

Frequently Asked Questions

When should I use Promise.all vs Promise.allSettled?
Use Promise.all when all operations must succeed — it rejects immediately if any promise rejects. Use Promise.allSettled when you want results from all operations regardless of individual failures, such as running multiple independent API calls and reporting which ones failed.
Can I use await outside an async function?
Yes — top-level await is supported in ES modules (files with type: 'module' in Node.js, or .mjs files). In CommonJS or non-module contexts you still need an async wrapper function.
What happens if I forget to await a Promise?
The function continues synchronously without waiting for the result. The Promise runs in the background and any rejection becomes an unhandled Promise rejection, which can crash Node.js processes or produce silent bugs in the browser.