Skip to content
Node.js nd async 4 min read

async/await in Depth

The async/await syntax is the modern way to work with asynchronous code in Node.js. It lets you write code that reads sequentially while still running non-blocking under the hood, replacing nested callbacks and long .then() chains with flat, linear logic. Crucially, async/await is not a new concurrency model — it is syntactic sugar over Promises. Understanding how it desugars is the key to using it correctly.

Declaring async functions

Any function prefixed with async returns a Promise. If the function returns a plain value, that value is wrapped in a resolved Promise; if it throws, the Promise rejects. Inside an async function you can use await to pause until a Promise settles, then resume with its resolved value.

async function getUser(id) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  const user = await res.json();
  return user.name;
}

const name = await getUser(42); // top-level await in an ES module
console.log(name);

async works on every function form: declarations, expressions, arrow functions, and object/class methods.

const load = async () => { /* ... */ };

class Repo {
  async findAll() { /* ... */ }
}

How it desugars to promises

An await expression does the same thing as .then() — it schedules the continuation as a microtask once the awaited Promise resolves. The two snippets below are equivalent.

// async/await
async function total() {
  const a = await getPrice('a');
  const b = await getPrice('b');
  return a + b;
}

// roughly the same as
function total() {
  return getPrice('a').then((a) =>
    getPrice('b').then((b) => a + b)
  );
}

Because await yields to the event loop, code after an await always runs in a later microtask — never synchronously. await also unwraps non-Promise values harmlessly, so await 5 simply yields 5 (after one microtask tick).

Awaiting a non-thenable still costs a microtask turn. It never blocks the event loop, so other I/O and timers continue to run while an async function is suspended.

Error handling with try/catch

A rejected Promise becomes a thrown exception at the await site, so you handle async failures with ordinary try/catch/finally. This unifies synchronous and asynchronous error handling under one mechanism.

async function fetchJson(url) {
  try {
    const res = await fetch(url);
    if (!res.ok) {
      throw new Error(`HTTP ${res.status}`);
    }
    return await res.json();
  } catch (err) {
    console.error('Request failed:', err.message);
    return null;
  } finally {
    console.log('Done attempting fetch');
  }
}

await fetchJson('https://api.example.com/broken');

Output:

Request failed: HTTP 404
Done attempting fetch

A common pitfall: return await something inside try keeps the await inside the try block so rejections are caught. A bare return something returns the Promise and lets the caller deal with the rejection — the local catch will not fire.

Awaiting in loops: sequential vs concurrent

This is where async/await is most often misused. Using await inside a for loop runs each operation one after another. That is correct when iterations depend on each other, but wasteful when they are independent.

// Sequential: each request waits for the previous one
async function sequential(ids) {
  const results = [];
  for (const id of ids) {
    results.push(await getUser(id)); // ~N x latency
  }
  return results;
}

To run independent operations concurrently, start them all first (collect the Promises), then await them together with Promise.all.

// Concurrent: all requests start at once
async function concurrent(ids) {
  const promises = ids.map((id) => getUser(id)); // fire them off
  return Promise.all(promises);                   // ~1 x latency
}

For three requests of ~100 ms each, the difference is stark:

Approachawait placementTotal timeUse when
Sequentialawait inside the loop~300 mseach step needs the previous result
ConcurrentPromise.all over an array~100 msiterations are independent

Array.prototype.forEach does not await its async callback — the loop returns immediately and the callbacks run detached. Use a for...of loop or map + Promise.all instead.

Note that await outside an async function is only legal at the top level of an ES module (see top-level await); in CommonJS modules you must wrap it in an async IIFE.

// CommonJS workaround
(async () => {
  const data = await fetchJson('https://api.example.com/users/1');
  console.log(data);
})();

Returning values and chaining

Because async functions always return a Promise, callers can either await them or use .then(). Returning a Promise from an async function flattens automatically — there is no nested Promise<Promise<T>>.

async function pipeline(id) {
  const user = await getUser(id);
  return enrich(user); // enrich returns a Promise; it is flattened
}

pipeline(7).then((u) => console.log(u));

Best practices

  • Reach for Promise.all / Promise.allSettled when iterations are independent; reserve in-loop await for genuinely sequential work.
  • Wrap awaited calls in try/catch and use return await inside try so local error handling actually catches rejections.
  • Never use await inside forEach; prefer for...of for sequential and map + Promise.all for concurrent.
  • Always attach a .catch or surrounding try to top-level awaits — an unhandled rejection can crash the process.
  • Remember every await yields a microtask; avoid awaiting in hot inner loops where the value is already available synchronously.
  • In CommonJS, wrap top-level async logic in an async IIFE since await is not allowed at module scope there.
Last updated June 14, 2026
Was this helpful?