JavaScript: Promise vs async/await

Shunku

JavaScript offers two ways to handle asynchronous operations: Promises with .then() chains and the more modern async/await syntax. While async/await is built on top of Promises, they have different ergonomics and use cases. Let's explore both approaches and learn when to use each.

A Quick Recap: What Are Promises?

A Promise represents a value that may not be available yet. It can be in one of three states:

stateDiagram-v2
    [*] --> Pending
    Pending --> Fulfilled: resolve(value)
    Pending --> Rejected: reject(error)
    Fulfilled --> [*]
    Rejected --> [*]
const promise = new Promise((resolve, reject) => {
  // Async operation
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("Data loaded");
    } else {
      reject(new Error("Failed to load"));
    }
  }, 1000);
});

Promise Chains vs async/await

Let's compare both approaches with the same task: fetching user data and their posts.

Promise Chain Approach

function getUserWithPosts(userId) {
  return fetch(`/api/users/${userId}`)
    .then(response => response.json())
    .then(user => {
      return fetch(`/api/users/${userId}/posts`)
        .then(response => response.json())
        .then(posts => ({ user, posts }));
    });
}

getUserWithPosts(1)
  .then(({ user, posts }) => {
    console.log(user, posts);
  })
  .catch(error => {
    console.error(error);
  });

async/await Approach

async function getUserWithPosts(userId) {
  const userResponse = await fetch(`/api/users/${userId}`);
  const user = await userResponse.json();

  const postsResponse = await fetch(`/api/users/${userId}/posts`);
  const posts = await postsResponse.json();

  return { user, posts };
}

try {
  const { user, posts } = await getUserWithPosts(1);
  console.log(user, posts);
} catch (error) {
  console.error(error);
}

The async/await version reads like synchronous code, making it easier to understand the flow.

Syntax Comparison

flowchart LR
    subgraph Promise Chain
        A["promise"] --> B[".then()"]
        B --> C[".then()"]
        C --> D[".catch()"]
    end

    subgraph async/await
        E["async function"] --> F["await promise1"]
        F --> G["await promise2"]
        G --> H["try/catch"]
    end

    style B fill:#f59e0b,color:#fff
    style C fill:#f59e0b,color:#fff
    style F fill:#10b981,color:#fff
    style G fill:#10b981,color:#fff
Feature Promise Chain async/await
Syntax .then(), .catch() await, try/catch
Readability Nested for sequential ops Linear, synchronous-looking
Error handling .catch() or second .then() arg try/catch blocks
Debugging Harder to step through Easier, like sync code

Error Handling

Promise: .catch() Method

fetchUser(1)
  .then(user => fetchPosts(user.id))
  .then(posts => console.log(posts))
  .catch(error => {
    // Catches any error in the chain
    console.error("Error:", error.message);
  });

async/await: try/catch

async function loadUserPosts() {
  try {
    const user = await fetchUser(1);
    const posts = await fetchPosts(user.id);
    console.log(posts);
  } catch (error) {
    // Same error handling pattern as sync code
    console.error("Error:", error.message);
  }
}

Handling Specific Errors

async function loadData() {
  try {
    const user = await fetchUser(1);

    try {
      const posts = await fetchPosts(user.id);
      return { user, posts };
    } catch (postsError) {
      // Handle posts error specifically
      return { user, posts: [] };
    }
  } catch (userError) {
    // Handle user error
    throw new Error("Could not load user");
  }
}

Parallel Execution

One common mistake with async/await is running operations sequentially when they could run in parallel.

Sequential (Slower)

async function loadAll() {
  const users = await fetchUsers();    // Wait...
  const posts = await fetchPosts();    // Wait...
  const comments = await fetchComments(); // Wait...
  return { users, posts, comments };
}
// Total time: users + posts + comments

Parallel with Promise.all (Faster)

async function loadAll() {
  const [users, posts, comments] = await Promise.all([
    fetchUsers(),
    fetchPosts(),
    fetchComments()
  ]);
  return { users, posts, comments };
}
// Total time: max(users, posts, comments)
gantt
    title Execution Time Comparison
    dateFormat X
    axisFormat %s

    section Sequential
    fetchUsers    :0, 3
    fetchPosts    :3, 5
    fetchComments :5, 7

    section Parallel
    fetchUsers    :0, 3
    fetchPosts    :0, 2
    fetchComments :0, 2

Promise Combinators

JavaScript provides several methods to handle multiple promises:

Promise.all - All must succeed

const results = await Promise.all([
  fetchUser(1),
  fetchUser(2),
  fetchUser(3)
]);
// Returns array of results
// Rejects if ANY promise rejects

Promise.allSettled - Get all results

const results = await Promise.allSettled([
  fetchUser(1),
  fetchUser(2), // This one fails
  fetchUser(3)
]);
// Returns array of { status, value/reason }
// Never rejects
// [
//   { status: "fulfilled", value: user1 },
//   { status: "rejected", reason: Error },
//   { status: "fulfilled", value: user3 }
// ]

Promise.race - First to complete

const result = await Promise.race([
  fetchFromServer1(),
  fetchFromServer2()
]);
// Returns first resolved/rejected result

Promise.any - First success

const result = await Promise.any([
  fetchFromServer1(), // Fails
  fetchFromServer2(), // Succeeds first
  fetchFromServer3()
]);
// Returns first fulfilled result
// Only rejects if ALL promises reject

When to Use Which

Use async/await When:

  • Sequential operations that depend on each other
  • Complex control flow (conditions, loops)
  • Cleaner error handling with try/catch
  • Easier debugging is needed
async function processItems(items) {
  const results = [];

  for (const item of items) {
    if (item.needsProcessing) {
      const result = await processItem(item);
      results.push(result);
    }
  }

  return results;
}

Use Promise Chains When:

  • Simple transformations
  • Functional programming style
  • Building reusable promise pipelines
function processUser(userId) {
  return fetchUser(userId)
    .then(user => enrichUserData(user))
    .then(user => validateUser(user))
    .then(user => saveUser(user));
}

Mix Both When Appropriate

async function complexOperation() {
  // Use await for clarity
  const user = await fetchUser(1);

  // Use Promise.all for parallel operations
  const [posts, followers] = await Promise.all([
    fetchPosts(user.id),
    fetchFollowers(user.id)
  ]);

  // Chain for simple transformations
  const enrichedPosts = await Promise.all(
    posts.map(post =>
      fetchComments(post.id)
        .then(comments => ({ ...post, comments }))
    )
  );

  return { user, posts: enrichedPosts, followers };
}

Common Patterns

Retry with Exponential Backoff

async function fetchWithRetry(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fetch(url);
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise(r => setTimeout(r, 2 ** i * 1000));
    }
  }
}

Timeout Wrapper

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Timeout")), ms)
  );
  return Promise.race([promise, timeout]);
}

// Usage
const data = await withTimeout(fetchData(), 5000);

Sequential Processing with Results

async function processSequentially(items, processor) {
  const results = [];
  for (const item of items) {
    results.push(await processor(item));
  }
  return results;
}

// Or with reduce for a more functional style
function processSequentially(items, processor) {
  return items.reduce(
    (promise, item) =>
      promise.then(results =>
        processor(item).then(result => [...results, result])
      ),
    Promise.resolve([])
  );
}

Summary

Aspect Promise Chains async/await
Readability Moderate High
Error handling .catch() try/catch
Debugging Harder Easier
Parallel ops Promise.all() Promise.all() + await
Sequential ops Nested .then() Linear code
Best for Simple chains, FP style Complex flows

Both approaches are valid and often work best together. Use async/await for most cases due to its readability, but don't hesitate to use Promise methods like Promise.all() when you need parallel execution or specific combinator behavior.

References

  • Flanagan, David. JavaScript: The Definitive Guide, 7th Edition. O'Reilly Media, 2020.
  • Osmani, Addy. Learning JavaScript Design Patterns, 2nd Edition. O'Reilly Media, 2023.