Promises & async/await Deep Dive

Reviewed & published by Brayan K

A Promise is an object representing a value that will be available later, and async/await is the syntax that lets you work with those future values as if the code ran top to bottom.

Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

By the end of this lesson you'll create promises, chain them with then/catch/finally, write clean async/await with try/catch, combine many promises with all, allSettled, race, and any, choose sequential versus parallel, and modernize callback APIs with util.promisify.

What You'll Learn in This Lesson

1️⃣ Creating & Consuming Promises

You create a promise with new Promise((resolve, reject) => {...}). Call resolve(value) on success or reject(error) on failure. Consumers attach .then for the result, .catch for errors, and .finally for cleanup that always runs.

// A Promise represents a value that will exist later (or an error).
function delay(ms, value) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

delay(50, 'done!')
  .then((result) => console.log('then:', result))       // success path
  .catch((err) => console.log('catch:', err.message))   // error path
  .finally(() => console.log('finally: always runs'));  // cleanup, always

The same logic reads far more naturally with async/await. Inside an async function, await pauses until the promise settles, and a rejection throws — so ordinary try/catch handles errors.

function fetchUser(id) {
  return new Promise((resolve, reject) => {
    if (id > 0) resolve({ id, name: 'Ada' });
    else reject(new Error('invalid id'));
  });
}

// async/await reads top-to-bottom; try/catch handles rejections.
async function main() {
  try {
    const user = await fetchUser(1);
    console.log('got user:', user.name);
    await fetchUser(-1);            // this rejects -> jumps to catch
  } catch (err) {
    console.log('caught:', err.message);
  }
}

main();

2️⃣ Combining Many Promises

Real apps juggle several async operations at once, and four static helpers cover the common cases. Promise.all waits for all and fails fast if any rejects. Promise.allSettled waits for all and never rejects, reporting each outcome. Promise.race settles with the first to finish either way. Promise.any resolves with the first success.

const ok = (v, ms = 10) => new Promise((res) => setTimeout(() => res(v), ms));
const fail = (msg, ms = 10) => new Promise((_, rej) => setTimeout(() => rej(new Error(msg)), ms));

// Promise.all: waits for ALL; rejects if ANY rejects.
console.log('all:', await Promise.all([ok(1), ok(2), ok(3)]));

// Promise.allSettled: waits for all, never rejects — reports each outcome.
const settled = await Promise.allSettled([ok('A'), fail('B failed')]);
console.log('allSettled:', settled.map((s) => s.status));

// Promise.race: settles with the FIRST to finish (resolve OR reject).
console.log('race:', await Promise.race([ok('fast', 5), ok('slow', 50)]));

// Promise.any: first to RESOLVE; ignores rejections unless all reject.
console.log('any:', await Promise.any([fail('x'), ok('winner', 20)]));

3️⃣ Sequential vs Parallel (and promisify)

One of the most common performance bugs is awaiting independent operations one at a time. If three calls each take 100ms and don't depend on each other, doing them sequentially costs ~300ms; starting them together with Promise.all costs only ~100ms. Await sequentially only when a later step truly needs an earlier result.

const task = (label, ms) => new Promise((res) => setTimeout(() => res(label), ms));

// SEQUENTIAL: each await waits for the previous one. ~300ms total.
async function sequential() {
  const start = Date.now();
  await task('a', 100);
  await task('b', 100);
  await task('c', 100);
  console.log('sequential took ~', Math.round((Date.now() - start) / 100) * 100, 'ms');
}

// PARALLEL: start all three, then await together. ~100ms total.
async function parallel() {
  const start = Date.now();
  await Promise.all([task('a', 100), task('b', 100), task('c', 100)]);
  console.log('parallel took ~', Math.round((Date.now() - start) / 100) * 100, 'ms');
}

await sequential();
await parallel();

Finally, plenty of older APIs still use error-first callbacks. util.promisify wraps one into a promise-returning function so you can await it.

import { promisify } from 'node:util';

// A classic Node-style callback function: (err, result) => ...
function getValueCb(n, callback) {
  setTimeout(() => {
    if (n < 0) callback(new Error('no negatives'));
    else callback(null, n * 2);
  }, 10);
}

// promisify turns it into a promise-returning function you can await.
const getValue = promisify(getValueCb);

console.log('doubled:', await getValue(21));

Your turn. The program below works once you fill in the two blanks marked ___. Follow the 👉 hints, then run it and compare with the expected output.

const fetchPrice = (item) =>
  new Promise((res) => setTimeout(() => res(item === 'apple' ? 3 : 5), 10));

async function main() {
  // 🎯 YOUR TURN — fill each ___ then run it.
  // Fetch BOTH prices in PARALLEL and add them.

  const [apple, banana] = await Promise.___([   // 👉 all
    fetchPrice('apple'),
    fetchPrice('banana'),
  ]);

  console.log('total:', apple ___ banana);       // 👉 + (the plus operator)
}

main();

// ✅ Expected output:
//    total: 8

Mini-Challenge: A Health-Check Report

No blanks this time — just a brief and an outline. Write it yourself, run it, and check your output against the example in the comments. This ties together Promise.allSettled and reading each result's status.

// 🎯 MINI-CHALLENGE: a health-check report
//
// Given check(name, willPass) that resolves with the name when willPass
// is true and rejects with an Error(name) when false:
//   - Run THREE checks together so one failure doesn't stop the others.
//   - Print "OK   <name>" for each that passed and "FAIL <name>" for each
//     that failed.
//   (Hint: Promise.allSettled gives you { status, value/reason } per item.)
//
// const check = (name, willPass) =>
//   new Promise((res, rej) =>
//     setTimeout(() => (willPass ? res(name) : rej(new Error(name))), 10));
//
// Run with: check('db', true), check('cache', false), check('api', true)
//
// ✅ Example output:
//    OK   db
//    FAIL cache
//    OK   api

// your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Combinators

HelperSettles when…
Promise.allall succeed (rejects on first failure)
Promise.allSettledall finish (never rejects)
Promise.racefirst to settle, success or fail
Promise.anyfirst to succeed

🎉 Lesson Complete!

Practice quiz

What does a Promise represent?

  • A value that will exist later, or an error
  • A synchronous return value
  • A network socket
  • A file handle

Answer: A value that will exist later, or an error. A Promise is a placeholder for a future value that eventually resolves or rejects.

Which handler runs whether a promise resolves or rejects?

  • .then
  • .catch
  • .finally
  • .all

Answer: .finally. .finally always runs after settling, making it ideal for cleanup.

How do you handle a rejected promise when using async/await?

  • With .map()
  • With a for loop
  • With try/catch around the await
  • With process.exit

Answer: With try/catch around the await. An awaited rejection throws at the await point, so a surrounding try/catch handles it.

What does Promise.all do if one of its promises rejects?

  • Ignores the rejection
  • Returns the resolved ones only
  • Waits for all then reports each
  • Rejects immediately with that error

Answer: Rejects immediately with that error. Promise.all is all-or-nothing: a single rejection rejects the whole thing and discards other results.

Which combinator waits for every promise and never rejects?

  • Promise.allSettled
  • Promise.all
  • Promise.race
  • Promise.any

Answer: Promise.allSettled. allSettled reports each outcome as fulfilled or rejected and never rejects itself.

What does Promise.race settle with?

  • The slowest promise
  • The first promise to settle, success or failure
  • Only the first success
  • An array of all results

Answer: The first promise to settle, success or failure. race settles as soon as the first promise settles, whether it resolves or rejects.

What does Promise.any resolve with?

  • The first promise to settle
  • The last to resolve
  • The first to resolve, ignoring rejections
  • Every resolved value

Answer: The first to resolve, ignoring rejections. any resolves with the first fulfillment and only rejects if all of them reject.

Three independent 100ms tasks awaited one at a time take roughly how long?

  • 100ms
  • 300ms
  • 0ms
  • 50ms

Answer: 300ms. Sequential awaits add up: 100 + 100 + 100 is about 300ms; Promise.all would be ~100ms.

What does util.promisify do?

  • Speeds up promises
  • Cancels a promise
  • Turns an error-first callback function into a promise-returning one
  • Logs promise rejections

Answer: Turns an error-first callback function into a promise-returning one. promisify wraps a Node-style (err, value) callback API so you can await it.

Why does logging a promise show 'Promise { <pending> }'?

  • The promise failed
  • You forgot to await it (or use .then)
  • The value is null
  • Node is out of memory

Answer: You forgot to await it (or use .then). Without await or .then you log the promise object itself, not its resolved value.

Continue this course

Frequently asked questions

What is the difference between Promise.all and Promise.allSettled?

Promise.all is all-or-nothing: it resolves with an array of every result once they all succeed, but if any single promise rejects, the whole thing rejects immediately with that error and you lose the other results. Promise.allSettled never rejects — it waits for every promise to finish and gives you an array describing each outcome as { status: 'fulfilled', value } or { status: 'rejected', reason }. Use all when you need every operation to succeed (and want to fail fast); use allSettled when you want a full report even if some fail, like running independent health checks.

When should I run promises sequentially versus in parallel?

Run them in parallel when the operations are independent — fetching three unrelated URLs, for example. Starting them together and awaiting with Promise.all means the total time is roughly the slowest one, not the sum. Run them sequentially (await one, then the next) only when each step depends on the previous step's result, or when you must limit load on a resource. A very common performance bug is awaiting independent calls one at a time in a loop, turning what could be 100ms of parallel work into 300ms or more.

How does error handling work with async/await?

A rejected promise that you await throws at the await point, so you catch it with a normal try/catch around the awaited call. If you don't catch it, the rejection propagates up and becomes an unhandled rejection. With plain promises you handle errors with .catch(). The two styles mix: you can await inside try/catch, or attach .catch() to a promise chain. Just make sure every promise has somewhere its rejection is handled — either a surrounding try/catch on the await, or a .catch() on the chain.

What is util.promisify for?

Many older Node APIs use the error-first callback style: doThing(args, (err, result) => ...). util.promisify wraps such a function so it returns a promise instead, letting you use await and try/catch rather than nesting callbacks. It works for any function whose last parameter is a Node-style (err, value) callback. Most core modules now ship promise versions directly (for example node:fs/promises), so reach for those first; use promisify for third-party or legacy callback APIs that don't offer one.