DTTooleras

JavaScript Promises and Async/Await: The Complete Tutorial

Master asynchronous JavaScript — from callbacks to Promises to async/await. Error handling, parallel execution, common patterns, and real-world examples.

DevToolsHub Team25 min read1,270 words

The Problem: Asynchronous Code

JavaScript is single-threaded. It can only do one thing at a time. But web applications need to fetch data from APIs, read files, query databases, and handle user input — all without freezing the UI.

This is where asynchronous programming comes in. Instead of waiting for a slow operation to complete, JavaScript starts the operation and moves on. When the result is ready, a callback function handles it.

The Evolution

Callbacks (1995) → Promises (ES2015) → Async/Await (ES2017)

Each step made async code easier to read and write.

Callbacks: The Original Approach

// Callback pattern
function fetchUser(id, callback) {
  setTimeout(() => {
    callback(null, { id, name: "Alice" });
  }, 1000);
}

fetchUser(1, (error, user) => {
  if (error) {
    console.error(error);
    return;
  }
  console.log(user);
});

Callback Hell

The problem with callbacks is nesting. When you need sequential async operations, you get the "pyramid of doom":

fetchUser(1, (err, user) => {
  fetchOrders(user.id, (err, orders) => {
    fetchOrderDetails(orders[0].id, (err, details) => {
      fetchShippingInfo(details.shippingId, (err, shipping) => {
        // 4 levels deep... and it gets worse
        console.log(shipping);
      });
    });
  });
});

This is unreadable, hard to debug, and error handling is a nightmare.

Promises: The Solution

A Promise is an object that represents the eventual completion (or failure) of an async operation. It has three states:

  • Pending — Initial state, operation in progress
  • Fulfilled — Operation completed successfully
  • Rejected — Operation failed
// Creating a Promise
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve({ id: 1, name: "Alice" });
    } else {
      reject(new Error("Failed to fetch user"));
    }
  }, 1000);
});

// Consuming a Promise
promise
  .then((user) => console.log(user))
  .catch((error) => console.error(error))
  .finally(() => console.log("Done"));

Chaining Promises

The real power of Promises is chaining. Each .then() returns a new Promise:

fetchUser(1)
  .then((user) => fetchOrders(user.id))
  .then((orders) => fetchOrderDetails(orders[0].id))
  .then((details) => fetchShippingInfo(details.shippingId))
  .then((shipping) => console.log(shipping))
  .catch((error) => console.error(error)); // Catches ANY error in the chain

Compare this to the callback hell version — it's flat, readable, and has centralized error handling.

Promise.all — Parallel Execution

When you need multiple async operations that don't depend on each other, run them in parallel:

const [user, posts, notifications] = await Promise.all([
  fetchUser(1),
  fetchPosts(1),
  fetchNotifications(1),
]);
// All three run simultaneously — total time = slowest one, not sum of all

Important: If ANY promise rejects, Promise.all rejects immediately. Use Promise.allSettled if you want all results regardless of failures.

Promise.allSettled

const results = await Promise.allSettled([
  fetchUser(1),      // succeeds
  fetchUser(999),    // fails
  fetchUser(2),      // succeeds
]);

results.forEach((result) => {
  if (result.status === "fulfilled") {
    console.log("Success:", result.value);
  } else {
    console.log("Failed:", result.reason);
  }
});

Promise.race

Returns the result of the first promise to settle (fulfill or reject):

// Timeout pattern
const result = await Promise.race([
  fetchData(),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Timeout")), 5000)
  ),
]);

Promise.any

Returns the first promise to fulfill (ignores rejections):

// Try multiple sources, use whichever responds first
const data = await Promise.any([
  fetchFromCDN1(),
  fetchFromCDN2(),
  fetchFromOrigin(),
]);

Async/Await: The Modern Way

async/await is syntactic sugar over Promises. It makes async code look and behave like synchronous code:

// Without async/await
function getUser(id) {
  return fetch(`/api/users/${id}`)
    .then((response) => response.json())
    .then((data) => data);
}

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

Rules of Async/Await

  1. await can only be used inside an async function (or at the top level of a module)
  2. async functions always return a Promise
  3. await pauses execution until the Promise settles

Error Handling with Try/Catch

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

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

    const data = await response.json();
    return data;
  } catch (error) {
    console.error("Failed to fetch user:", error.message);
    throw error; // Re-throw if you want callers to handle it
  }
}

Sequential vs Parallel

// ❌ Sequential — each waits for the previous one (slow)
const user = await fetchUser(1);
const posts = await fetchPosts(1);
const comments = await fetchComments(1);
// Total time: fetchUser + fetchPosts + fetchComments

// ✅ Parallel — all run at the same time (fast)
const [user, posts, comments] = await Promise.all([
  fetchUser(1),
  fetchPosts(1),
  fetchComments(1),
]);
// Total time: max(fetchUser, fetchPosts, fetchComments)

Async Iteration

// Process items one at a time
async function processItems(items) {
  for (const item of items) {
    await processItem(item); // Sequential
  }
}

// Process items in parallel with concurrency limit
async function processWithLimit(items, limit = 5) {
  const chunks = [];
  for (let i = 0; i < items.length; i += limit) {
    chunks.push(items.slice(i, i + limit));
  }
  for (const chunk of chunks) {
    await Promise.all(chunk.map(processItem));
  }
}

Real-World Patterns

Fetch with Retry

async function fetchWithRetry(url, options = {}, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, i))); // Exponential backoff
    }
  }
}

Debounced Async Search

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    return new Promise((resolve) => {
      timer = setTimeout(async () => {
        resolve(await fn(...args));
      }, delay);
    });
  };
}

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

Loading State Pattern

function useAsync(asyncFn) {
  const [state, setState] = useState({
    data: null,
    loading: false,
    error: null,
  });

  const execute = async (...args) => {
    setState({ data: null, loading: true, error: null });
    try {
      const data = await asyncFn(...args);
      setState({ data, loading: false, error: null });
      return data;
    } catch (error) {
      setState({ data: null, loading: false, error });
      throw error;
    }
  };

  return { ...state, execute };
}

Common Mistakes

1. Forgetting to await

// ❌ Bug: response is a Promise, not the actual response
const response = fetch("/api/data");
console.log(response); // Promise { <pending> }

// ✅ Correct
const response = await fetch("/api/data");

2. Using await in a loop when parallel is possible

// ❌ Slow: sequential
for (const id of ids) {
  const user = await fetchUser(id);
  users.push(user);
}

// ✅ Fast: parallel
const users = await Promise.all(ids.map(fetchUser));

3. Swallowing errors

// ❌ Error is silently ignored
async function getData() {
  try {
    return await fetchData();
  } catch (error) {
    // Nothing here — error disappears
  }
}

// ✅ Handle or re-throw
async function getData() {
  try {
    return await fetchData();
  } catch (error) {
    console.error("Failed:", error);
    return null; // Explicit fallback
  }
}

Quick Reference

MethodBehaviorUse When
Promise.allFails fast on first rejectionAll must succeed
Promise.allSettledWaits for all, reports eachNeed all results
Promise.raceFirst to settle winsTimeout patterns
Promise.anyFirst to fulfill winsFallback sources
awaitPauses until resolvedSequential flow

Related Tools

javascript promisesasync awaitpromiseasynchronous javascriptpromise.allfetch apijavascript tutorial

Related articles

All articles

Practice with free tools

200+ free developer tools that run in your browser.

Browse all tools →