TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

JavaScript Promises vs. async/await: What Actually Changes?

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 20267 min read5 topics
JavaScript Promises vs. async/await: What Actually Changes?
Explore the details
#JavaScript#Promises#async-await#Asynchronous JavaScript#Promise.all
October 25, 20267 min read

JavaScript Promises vs. async/await: What Actually Changes?

Promises and async/await are two ways to work with asynchronous results in JavaScript. The difference can look bigger than it is: async/await changes how asynchronous code is written and read, but it does not replace promises. Underneath, await works with promises, and every call to an async function returns a promise.

Understanding that connection helps you choose the clearest structure for a task, handle errors reliably, and avoid accidentally running operations one after another when you intended to run them concurrently.

Key takeaways

  • A promise represents a result that may be available now, later, or never because the operation failed.
  • async/await is syntax for working with promises; it does not make an operation synchronous.
  • Use Promise.all() when independent tasks can run concurrently and you need all their results.
  • Use await in sequence when a later operation depends on an earlier result.
  • Handle rejections with .catch() or try/catch, and remember that Promise.all() does not cancel other work when one promise rejects.

The promise model

A promise has one of three states: pending, fulfilled, or rejected. Once it settles—by fulfilling or rejecting—it does not change state again. A fulfilled promise has a value; a rejected promise has a reason, usually an Error object.

You can attach callbacks with .then() for fulfillment and .catch() for rejection. Each call to .then() or .catch() returns a new promise, so handlers can return a value or another promise and pass the resulting value down the chain.

function getUser(userId) {
  return fetch(`/api/users/${userId}`).then((response) => {
    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    return response.json();
  });
}

getUser(42)
  .then((user) => console.log(user.name))
  .catch((error) => console.error("Could not load user:", error));

The callback passed to a promise's .then() runs after the promise settles. Returning response.json() from the first handler matters: it lets the next handler wait for that promise and receive its decoded result.

What async/await changes—and what it does not

An async function always returns a promise. Returning a plain value fulfills that promise with the value; throwing an error rejects it. Inside an async function, await pauses that function until the awaited value settles, then either produces its fulfillment value or throws its rejection reason. Other JavaScript work can continue while the function is suspended.

Here is the same request written with async/await:

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

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

  return response.json();
}

getUser(42)
  .then((user) => console.log(user.name))
  .catch((error) => console.error("Could not load user:", error));

The function's caller still receives a promise, so it can use .then() and .catch() as before. The function could also be called from another async function with await.

await accepts promises and other values. If the value is not a promise, it is treated like an already-fulfilled value. It does not block the JavaScript thread while waiting for a pending promise.

Error handling: catch versus try/catch

With promise chains, attach .catch() to handle a rejection. A thrown error inside a .then() callback also rejects the promise returned by that .then(), so it can reach a later .catch().

loadProfile()
  .then((profile) => saveProfile(profile))
  .catch((error) => {
    console.error("Loading or saving failed:", error);
  });

With async/await, a rejected awaited promise behaves like a thrown error at that line. Use try/catch to handle it:

async function updateProfile() {
  try {
    const profile = await loadProfile();
    return await saveProfile(profile);
  } catch (error) {
    console.error("Loading or saving failed:", error);
    throw error;
  }
}

The rethrow is intentional here: it records the error and still lets the caller know the operation failed. If you catch an error and do not rethrow or return a rejected promise, the function may instead complete successfully with undefined or another fallback value. That can hide failures.

You do not always need return await. For a simple return, return saveProfile(profile) passes the promise through. return await can be useful inside a try block when you want a rejection from that promise to be caught by the same catch before the function returns.

Sequential execution

Use sequential await when one operation needs the result of another. For example, the second request cannot be built until the first request provides an account ID:

async function loadAccountAndOrders(username) {
  const account = await fetchAccount(username);
  const orders = await fetchOrders(account.id);

  return { account, orders };
}

The promise-chain equivalent makes the dependency explicit through returned promises:

function loadAccountAndOrders(username) {
  return fetchAccount(username).then((account) => {
    return fetchOrders(account.id).then((orders) => ({ account, orders }));
  });
}

In both examples, the orders request starts only after the account request fulfills. This is appropriate when there is a real dependency, but unnecessary waiting is a common performance mistake when tasks are independent.

Concurrent execution with Promise.all

If two operations are independent, start both before waiting for either result. Promise.all() fulfills with an array of results in the same order as the input promises, even if those promises finish in a different order.

async function loadDashboard(userId) {
  const profilePromise = fetchProfile(userId);
  const notificationsPromise = fetchNotifications(userId);

  const [profile, notifications] = await Promise.all([
    profilePromise,
    notificationsPromise,
  ]);

  return { profile, notifications };
}

The equivalent promise-based pattern is:

function loadDashboard(userId) {
  return Promise.all([
    fetchProfile(userId),
    fetchNotifications(userId),
  ]).then(([profile, notifications]) => ({ profile, notifications }));
}

Promise.all() rejects as soon as one input promise rejects. It does not cancel the other operations; they may continue running. If you need to observe the outcome of every operation regardless of failure, consider Promise.allSettled() and handle each result's status.

A common concurrency mistake: awaiting inside a loop

This loop waits for each task to finish before starting the next one:

async function loadItems(ids) {
  const items = [];

  for (const id of ids) {
    items.push(await fetchItem(id));
  }

  return items;
}

That is correct if each request depends on the previous result or if you deliberately need sequential behavior. If the requests are independent, start them together and wait for all of them:

async function loadItems(ids) {
  return Promise.all(ids.map((id) => fetchItem(id)));
}

This starts one operation per ID. For a very large list, launching every operation at once may use too many resources or exceed a service's rate limits. In that case, use a deliberate concurrency limit rather than assuming that Promise.all() limits parallel work; it does not.

Another subtle mistake is creating promises and then forgetting to wait for or return them:

async function saveAll(records) {
  records.forEach(async (record) => {
    await saveRecord(record);
  });
}

forEach() does not wait for promises returned by its callback. As written, saveAll() can finish before the saves do, and failures may not be handled where expected. For concurrent saves, use Promise.all(records.map(...)). For sequential saves, use a for...of loop with await.

Choosing between promises and async/await

Use whichever form makes the flow easiest to understand and maintain. async/await often reads naturally for multi-step workflows with branching or try/catch. Promise methods can be concise for transformations, composition, and operations such as Promise.all() or Promise.allSettled().

They are not mutually exclusive. An async function can return a promise chain, and promise callbacks can call async functions. Aim for consistent error handling and avoid adding unnecessary layers of .then() or wrapping a promise in a new Promise when you can return the original one.

Common mistakes and practical checks

  • Assuming async makes work happen in the background: It does not change whether the underlying operation is asynchronous or make CPU-heavy JavaScript non-blocking.
  • Forgetting to return a promise: In a .then() callback, return the next promise if the chain should wait for it.
  • Catching and swallowing errors: Decide whether to recover with a fallback or propagate the failure to the caller.
  • Awaiting independent work one item at a time: Start independent tasks before awaiting them, often with Promise.all().
  • Assuming Promise.all() cancels work or limits concurrency: It does neither. Cancellation and concurrency limits require separate mechanisms.
  • Using forEach() for async sequencing: Use for...of for sequential work or Promise.all() with map() for concurrent work.
  • Leaving returned promises unhandled: Make sure the caller awaits the promise, returns it to another caller, or attaches appropriate rejection handling.

Conclusion

Promises are the underlying mechanism; async/await is syntax that makes promise-based control flow easier to read in many situations. Choose sequential await when steps depend on one another, and use Promise.all() for independent tasks that should run concurrently. In either style, return promises deliberately and make error-handling behavior clear.

References