Home / Articles

Refactoring Promise Chains to Async/Await Without Changing Behavior

How to turn .then() chains into readable async/await code, what await really pauses, when to run steps in parallel, and how try/catch replaces .catch().

Publisher: REACTAPP.TOP7 min read1597 words

This article is published in English.

Sources: Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Promises rescued JavaScript from nested callbacks, but a chain of dependent .then() steps still reads like plumbing rather than a plain sequence of instructions. async/await closes that gap by letting asynchronous code look like ordinary top-to-bottom code while keeping promises underneath. This guide shows how to convert a promise chain safely, what await actually pauses, when sequential awaits waste time, and how error handling changes, so you can refactor existing code without altering what it does.

From a then chain to a linear function

Here is a typical chain in which each step depends on the result of the previous one: load a user, then their orders, then the receipts for those orders, with one handler for any failure.

getUser(id)
  .then(user => getOrders(user.id))
  .then(orders => getReceipts(orders))
  .then(receipts => {
    console.log(receipts);
  })
  .catch(err => handleError(err));

It is correct, and far better than nested callbacks, but you still have to trace the chain visually to follow the data. async/await, standardized in ES2017, was designed to remove that last bit of friction. The same logic rewritten looks like this:

async function loadData(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const receipts = await getReceipts(orders);
    console.log(receipts);
  } catch (err) {
    handleError(err);
  }
}

The behavior matches the chain: each step starts only after the previous one finishes, and any failure lands in the single error handler. What changed is readability. Each intermediate value now has a name, and the flow reads from top to bottom.

Keep one framing in mind throughout: async/await does not replace promises. It is syntactic sugar over them. Promises still do the asynchronous work; the syntax only changes how you write and read it.

What the async keyword changes

Putting async in front of a function has two effects. The function below returns a plain object:

async function getUser(id) {
  return { id, name: "Aarav" };
}

It always returns a promise

Even though getUser returns an ordinary object, calling it gives you a promise that resolves to that object. Callers therefore consume it like any other promise:

getUser(1).then(user => console.log(user));
// { id: 1, name: "Aarav" }

If the function throws, the returned promise rejects instead of the error propagating synchronously. This is easy to forget when a function that used to be synchronous becomes async: every caller that used its return value directly now receives a promise and must be updated.

It allows await inside its body

await is valid inside an async function. Modern JavaScript also allows top-level await in ES modules, which is convenient for scripts and module initialization, but in regular functions you still need async.

What await actually pauses

An await expression hands control back until its promise is resolved one way or the other. Success makes the expression produce the resolved value; failure makes that line throw, just like a throw statement would. The example logs a message, waits for the user, and then logs the result:

async function loadUser() {
  console.log("Fetching user...");
  const user = await getUser(1); // pauses here until getUser's promise settles
  console.log("Got user:", user);
}

The output appears in this order:

Fetching user...
Got user: { id: 1, name: "Aarav" }

The important detail is what "pausing" means. Only this one function is suspended, at that exact line. The rest of the program, including other code, timers and incoming requests on a server, keeps running. When the promise settles, the function resumes where it stopped, with the resolved value assigned to the variable.

Sequential awaits and their hidden cost

Several awaits in a row run one after another:

async function loadDashboard(id) {
  const user = await getUser(id);
  const orders = await getOrders(user.id);
  const receipts = await getReceipts(orders);
  return receipts;
}

Each line waits for its own step before the next begins, which reproduces the chained .then() behavior without the chain. That is correct when each step needs the previous result, as here: you cannot fetch orders before you know the user.

Problems start when the same style is applied to operations that do not depend on each other. In the dashboard loader below, the user, notifications and recommendations are independent, yet each request waits for the one before it:

async function loadDashboard() {
  const user = await getUser();
  const notifications = await getNotifications();
  const recommendations = await getRecommendations();

  return { user, notifications, recommendations };
}

The total time becomes the sum of all three requests instead of the slowest one.

Starting independent work together

Because the three calls are independent, you can start them at once and wait for all of them with Promise.all():

async function loadDashboard() {
  const [user, notifications, recommendations] = await Promise.all([
    getUser(),
    getNotifications(),
    getRecommendations()
  ]);

  return { user, notifications, recommendations };
}

All three requests are in flight concurrently, and destructuring gives you the results in the same order as the input array. One behavior to know: Promise.all() fulfills only when every promise fulfills, and it rejects as soon as any one rejects, without waiting for the rest. If you want every outcome regardless of failures, Promise.allSettled() is the better fit. The trade-offs between these combinators are covered in choosing between Promise.all, Promise.race and sequential awaits.

Error handling with try and catch

With async/await, the familiar try/catch block replaces .catch(). so synchronous and asynchronous failures finally share one construct:

async function loadUser(id) {
  try {
    const user = await getUser(id);
    console.log(user);
  } catch (err) {
    console.log("Failed to load user:", err.message);
  }
}

If the promise returned by getUser(id) rejects, control jumps straight to catch, exactly as it would for an error thrown by synchronous code. Notice that this function handles the error and then finishes normally, so its own promise fulfills with undefined. Callers cannot tell that loading failed. When a caller needs to react, rethrow after logging or return an explicit result instead.

One handler for several steps

A single try block can cover a whole sequence of awaits:

async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const receipts = await getReceipts(orders);
    return receipts;
  } catch (err) {
    console.log("Something failed in the dashboard load:", err.message);
  }
}

Whichever step fails, execution moves directly to catch, so you do not need a handler per step. This mirrors the centralizing effect .catch() had at the end of a chain. When different steps need different recovery, for example retrying only the receipts request, nest a narrower try around that step.

Comparing both styles side by side

Here is the same loader in both forms:

// Promises
function loadDashboard(id) {
  return getUser(id)
    .then(user => getOrders(user.id))
    .then(orders => getReceipts(orders))
    .catch(err => console.log("Failed:", err.message));
}

// async/await
async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    return await getReceipts(orders);
  } catch (err) {
    console.log("Failed:", err.message);
  }
}

Neither version changes the asynchronous work underneath. The choice is about which one people can read and maintain more easily, and for multi-step logic async/await usually wins. One subtle point in the second version: return await getReceipts(orders) inside try matters. Writing return getReceipts(orders) without await would hand the promise back before it settles, so a rejection from that last step would escape the catch block entirely.

Common questions

Does await block the whole application?

No. It suspends only the async function that contains it. Other code, including other requests, continues while that function waits.

Can you use await outside an async function?

Not in regular functions. The exception is top-level await in ES modules, which works at the module level but not inside ordinary non-async functions.

Is async/await faster than promises?

No. It is built on promises, so there is no speed advantage to expect from switching syntax. The benefit is readability and maintainability. Real speedups come from running independent work concurrently, not from the keyword.

What happens without try/catch?

If an awaited promise rejects and nothing catches it, the async function's own promise rejects. If no caller handles that rejection either, the runtime reports an unhandled rejection, which in current Node.js versions terminates the process by default. Handle errors with try/catch inside the function or with .catch() where it is called. For more traps of this kind, see common async/await misconceptions that cause production bugs.

Key takeaways

  • async/await changes how asynchronous code is written, not how it runs; promises still do the work.
  • An async function always returns a promise, so converting a function to async changes its contract for every caller.
  • await suspends only its own function, never the whole program.
  • Await dependent steps in sequence, but start independent ones together with Promise.all() or Promise.allSettled().
  • A try/catch around several awaits centralizes error handling; remember to rethrow when callers need to know, and to return await inside try.