Backend Development 17 min read

Mastering async/await in Node.js: Clean Code, Error Handling, and Patterns

This article explains how async/await simplifies callback and Promise code in Node.js, covering async function basics, error handling, retry strategies, parallel execution, array iteration pitfalls, and step‑by‑step refactoring of callback‑ and Promise‑based applications into clear, maintainable async/await code.

Code Mala Tang
Code Mala Tang
Code Mala Tang
Mastering async/await in Node.js: Clean Code, Error Handling, and Patterns

In this article we discuss how to use async/await to simplify those headache‑inducing callback and Promise code.

Node.js is an asynchronous event‑driven JavaScript runtime, especially suitable for building high‑concurrency network applications. It has no lock mechanism, so deadlocks do not exist.

What are async functions in Node.js?

In Node.js, async functions are built‑in; you just prepend the async keyword to a function and it automatically becomes asynchronous. Such a function always returns a Promise, even if you return nothing. The await keyword can only be used inside an async function, not in the global scope.

Inside an async function you can await any Promise or catch its rejection reason.

Example: converting Promise logic to async/await

<code>function handler (req, res) {
  return request('https://user-handler-service')
    .catch(err => {
      logger.error('Http error', err);
      error.logged = true;
      throw err;
    })
    .then(response => Mongo.findOne({ user: response.body.user }))
    .catch(err => {
      !error.logged && logger.error('Mongo error', err);
      error.logged = true;
      throw err;
    })
    .then(document => executeLogic(req, res, document))
    .catch(err => {
      !error.logged && console.error(err);
      res.status(500).send();
    });
}
</code>

Rewritten with async/await the code looks as clear as synchronous code:

<code>async function handler (req, res) {
  let response;
  try {
    response = await request('https://user-handler-service');
  } catch (err) {
    logger.error('Http error', err);
    return res.status(500).send();
  }

  let document;
  try {
    document = await Mongo.findOne({ user: response.body.user });
  } catch (err) {
    logger.error('Mongo error', err);
    return res.status(500).send();
  }

  executeLogic(document, req, res);
}
</code>

If you ignore unhandled Promise rejections, Node.js will warn you. It is recommended to let the process crash in such cases, which can be achieved with the --unhandled-rejections=strict flag or by handling the unhandledRejection event:

<code>process.on('unhandledRejection', (err) => {
  console.error(err);
  process.exit(1);
});
</code>

Future Node.js versions will automatically exit the process, so preparing your code now avoids surprises later.

Async functions in JavaScript

Async functions make asynchronous operations appear synchronous, eliminating extra callbacks and reducing nesting.

Retry with exponential backoff

Implementing retry logic with Promises is cumbersome:

<code>function request(url) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(`Network error when trying to reach ${url}`);
    }, 500);
  });
}

function requestWithRetry(url, retryCount, currentTries = 1) {
  return new Promise((resolve, reject) => {
    if (currentTries <= retryCount) {
      const timeout = (Math.pow(2, currentTries) - 1) * 100;
      request(url)
        .then(resolve)
        .catch(error => {
          setTimeout(() => {
            console.log('Error: ', error);
            console.log(`Waiting ${timeout} ms`);
            requestWithRetry(url, retryCount, currentTries + 1);
          }, timeout);
        });
    } else {
      console.log('No retries left, giving up.');
      reject('No retries left, giving up.');
    }
  });
}

requestWithRetry('http://localhost:3000')
  .then(res => {
    console.log(res);
  })
  .catch(err => {
    console.error(err);
  });
</code>

Rewritten with async/await the code becomes much cleaner:

<code>async function requestWithRetry(url) {
  const MAX_RETRIES = 10;
  for (let i = 0; i <= MAX_RETRIES; i++) {
    try {
      return await request(url);
    } catch (err) {
      const timeout = Math.pow(2, i);
      console.log('Waiting', timeout, 'ms');
      await wait(timeout);
      console.log('Retrying', err.message, i);
    }
  }
}
</code>

Intermediate values

If you have three async functions that depend on each other, you need to choose a less elegant solution.

For example, functionA returns a Promise, then functionB needs that value, and functionC needs the values from both functionA and functionB .

Solution 1: .then deep nesting

<code>function executeAsyncTask() {
  return functionA()
    .then(valueA => {
      return functionB(valueA)
        .then(valueB => {
          return functionC(valueA, valueB);
        })
    })
}
</code>

This solution obtains valueA from the outer closure and valueB from the previous Promise. It cannot flatten the nesting without losing the closure, making valueA unavailable for functionC .

Solution 2: Move to higher scope

<code>function executeAsyncTask() {
  let valueA;
  return functionA()
    .then(v => {
      valueA = v;
      return functionB(valueA);
    })
    .then(valueB => {
      return functionC(valueA, valueB);
    })
}
</code>

By declaring valueA in a higher scope, it becomes accessible after the first Promise resolves. This flattens the .then chain but introduces the risk of accidental reuse of the variable.

Solution 3: Unnecessary array

<code>function executeAsyncTask() {
  return functionA()
    .then(valueA => {
      return Promise.all([valueA, functionB(valueA)])
    })
    .then(([valueA, valueB]) => {
      return functionC(valueA, valueB);
    })
}
</code>

There is no strong reason to pass valueA in an array together with functionB ; the two values may be of completely different types.

Solution 4: Write a helper function

<code>const converge = (...promises) => (...args) => {
  let [head, ...tail] = promises;
  if (tail.length) {
    return head(...args)
      .then(value => converge(...tail)(...args.concat([value])))
  } else {
    return head(...args);
  }
}

functionA(2)
  .then(valueA => converge(functionB, functionC)(valueA))
</code>

You can write a helper to hide the juggling of context, but it becomes hard to read and may not be straightforward for developers unfamiliar with such function tricks.

Using async/await makes the problem disappear

<code>async function executeAsyncTask() {
  const valueA = await functionA();
  const valueB = await functionB(valueA);
  return function3(valueA, valueB);
}
</code>

Multiple parallel requests with async/await

If you want to run several async tasks simultaneously and use their results in different places, you can easily do it with async/await :

<code>async function executeParallelAsyncTasks() {
  const [ valueA, valueB, valueC ] = await Promise.all([ functionA(), functionB(), functionC() ]);
  doSomethingWith(valueA);
  doSomethingElseWith(valueB);
  doAnotherThingWith(valueC);
}
</code>

As seen before, you either need to move these values to a higher scope or create a non‑semantic array to pass them around.

Array iteration methods

You can use map , filter and reduce with async functions, although their behavior can be unintuitive. Consider the following scripts:

map

<code>function asyncThing (value) {
  return new Promise(resolve => {
    setTimeout(() => resolve(value), 100);
  });
}

async function main () {
  return [1,2,3,4].map(async (value) => {
    const v = await asyncThing(value);
    return v * 2;
  });
}

main()
  .then(v => console.log(v))
  .catch(err => console.error(err));
</code>

filter

<code>function asyncThing (value) {
  return new Promise(resolve => {
    setTimeout(() => resolve(value), 100);
  });
}

async function main () {
  return [1,2,3,4].filter(async (value) => {
    const v = await asyncThing(value);
    return v % 2 === 0;
  });
}

main()
  .then(v => console.log(v))
  .catch(err => console.error(err));
</code>

reduce

<code>function asyncThing (value) {
  return new Promise(resolve => {
    setTimeout(() => resolve(value), 100);
  });
}

async function main () {
  return [1,2,3,4].reduce(async (acc, value) => {
    return await acc + await asyncThing(value);
  }, Promise.resolve(0));
}

main()
  .then(v => console.log(v))
  .catch(err => console.error(err));
</code>

Results:

[ Promise { <pending> }, Promise { <pending> }, Promise { <pending> }, Promise { <pending> } ]

[ 1, 2, 3, 4 ]

10

Using map you get an array of pending promises; to obtain the actual values you must unwrap them with Promise.all :

<code>main()
  .then(v => Promise.all(v))
  .then(v => console.log(v))
  .catch(err => console.error(err));
</code>

Alternatively, await all promises first and then map the values:

<code>function main () {
  return Promise.all([1,2,3,4].map(value => asyncThing(value)));
}

main()
  .then(values => values.map(value => value * 2))
  .then(v => console.log(v))
  .catch(err => console.error(err));
</code>

This looks simpler, doesn’t it?

The async/await version is still useful when your iterator contains long‑running synchronous logic together with another long‑running async task, because you can start the next computation as soon as the first value is available.

With filter , the returned array is [ false, true, false, true ] , but each element is wrapped in a Promise that resolves to a truthy value, so the original array is returned. You need to resolve all values first and then filter them.

With reduce , you must wrap the initial accumulator in Promise.resolve and await the accumulator inside the reducer.

To keep your .then chain “pure”, you can use Ramda’s pipeP and composeP functions.

Rewrite callback‑based Node.js applications

Async functions always return a Promise , so you can rewrite any callback‑based function to return a Promise and then await its resolution. Use Node.js’s util.promisify to convert callback‑based functions into Promise‑based ones.

Rewrite Promise‑based applications

A simple .then chain can be upgraded directly to async/await :

<code>function asyncTask () {
  return functionA()
    .then(valueA => functionB(valueA))
    .then(valueB => functionC(valueB))
    .then(valueC => functionD(valueC))
    .catch(err => logger.error(err))
}
</code>

Will become:

<code>async function asyncTask () {
  try {
    const valueA = await functionA();
    const valueB = await functionB(valueA);
    const valueC = await functionC(valueB);
    return await functionD(valueC);
  } catch (err) {
    logger.error(err);
  }
}
</code>

Using async/await to rewrite Node.js applications

If you like the old if‑else conditionals and for/while loops,

If you think try‑catch blocks are the way to handle errors,

You will greatly enjoy rewriting your services with async/await .

As we have seen, it makes several patterns easier to code and read, so in many cases it is preferable to Promise.then() chains.

JavaScriptNode.jserror handlingasync/awaitPromises
Code Mala Tang
Written by

Code Mala Tang

Read source code together, write articles together, and enjoy spicy hot pot together.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.