March 26, 20265 min read

JavaScript Promises From the Ground Up

Promise states, chaining, error propagation, Promise.all/allSettled/race/any, converting callbacks, and when to reach for async/await instead.

javascript promises async callbacks fundamentals
Ad 336x280

Async/await is what you'll write day-to-day. But async/await is just syntax sugar over promises, and when something goes wrong — a .catch fires unexpectedly, Promise.all rejects when you didn't expect it, an error vanishes into the void — you need to understand what's actually happening underneath.

What a Promise is

A Promise is an object representing a value that doesn't exist yet but will (or won't) in the future. It has three states:

  • Pending — still waiting
  • Fulfilled — completed successfully, has a value
  • Rejected — failed, has a reason (usually an Error)
Once a promise settles (fulfilled or rejected), it never changes state again. It's done.

Creating a Promise

const p = new Promise((resolve, reject) => {
  const data = fetchSomeData();
  if (data) {
    resolve(data);   // fulfills the promise
  } else {
    reject(new Error('No data'));  // rejects it
  }
});

You rarely write new Promise() directly — most async APIs return promises already. But it's useful when wrapping older callback-based code (more on that below).

.then, .catch, .finally

fetch('/api/users')
  .then(response => response.json())
  .then(users => {
    console.log(users);
  })
  .catch(err => {
    console.error('Failed:', err.message);
  })
  .finally(() => {
    hideLoadingSpinner();
  });
.then receives the fulfilled value. .catch receives the rejection reason. .finally runs regardless of outcome and gets no arguments.

The key thing about chaining: each .then returns a new promise. Whatever you return from a .then callback becomes the value of that new promise. If you return another promise, the chain waits for it.

getUser(1)
  .then(user => getOrders(user.id))    // returns a promise
  .then(orders => orders.filter(o => o.total > 100))  // returns a value
  .then(bigOrders => console.log(bigOrders));

Error propagation

Errors flow down the chain until they hit a .catch. This is the part that trips people up:

doStep1()
  .then(result => doStep2(result))
  .then(result => doStep3(result))
  .catch(err => {
    // catches errors from ANY of the three steps
    console.error(err);
  });

If doStep1 rejects, doStep2 and doStep3 are skipped entirely. The error jumps straight to .catch. Same behavior if any .then callback throws.

One gotcha: if your .catch itself throws (or returns a rejected promise), that error needs another .catch downstream, or it becomes an unhandled rejection.

Promise.all — fail-fast parallel

const [users, products, orders] = await Promise.all([
  fetch('/api/users').then(r => r.json()),
  fetch('/api/products').then(r => r.json()),
  fetch('/api/orders').then(r => r.json()),
]);

All three requests fire in parallel. Promise.all resolves with an array of results only if all succeed. If any one rejects, the whole thing rejects immediately. The other promises still run — they just get ignored.

Promise.allSettled — get everything, failures included

const results = await Promise.allSettled([
  fetch('/api/critical'),
  fetch('/api/optional'),
  fetch('/api/nice-to-have'),
]);

results.forEach(r => {
if (r.status === 'fulfilled') {
console.log('Got:', r.value);
} else {
console.warn('Failed:', r.reason);
}
});

Never rejects. You get an array of { status, value } or { status, reason } objects. Use this when you want all results even if some fail.

Promise.race — first one wins

const result = await Promise.race([
  fetch('/api/data'),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error('Timeout')), 5000)
  ),
]);

Resolves or rejects with whichever promise settles first. The timeout pattern above is the most common real-world use.

Promise.any — first success wins

const fastest = await Promise.any([
  fetch('https://cdn1.example.com/data.json'),
  fetch('https://cdn2.example.com/data.json'),
  fetch('https://cdn3.example.com/data.json'),
]);

Like race, but ignores rejections. Only rejects if all promises reject (throws an AggregateError). Good for redundancy patterns.

Converting callback APIs to promises

Old Node.js style callbacks can be wrapped:

function readFileAsync(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, 'utf8', (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

// Now you can:
const content = await readFileAsync('./config.json');

Node.js also has util.promisify() which does this automatically for functions that follow the (err, result) callback convention:

const readFile = util.promisify(fs.readFile);
const content = await readFile('./config.json', 'utf8');

Most modern APIs return promises natively now, so you'll need this less and less.

Why async/await is (almost always) better

// Promise chain
function getOrderTotal(userId) {
  return getUser(userId)
    .then(user => getOrders(user.id))
    .then(orders => orders.reduce((sum, o) => sum + o.total, 0));
}

// async/await
async function getOrderTotal(userId) {
const user = await getUser(userId);
const orders = await getOrders(user.id);
return orders.reduce((sum, o) => sum + o.total, 0);
}

The async/await version reads top-to-bottom like synchronous code. Error handling uses regular try/catch. Debugging is easier because the stack trace actually makes sense.

But you should understand promises because:

  1. async functions return promises. You need to know what that means.
  2. Promise.all and friends don't have await equivalents — you use them directly.
  3. When things break, the error messages reference promise internals.
  4. Library code and older codebases use .then chains heavily.

Common mistakes

Forgetting to return inside .then:
// Bug — getOrders result is lost
getUser(1).then(user => {
  getOrders(user.id); // missing return!
}).then(orders => {
  console.log(orders); // undefined
});
Creating promises unnecessarily:
// Don't do this — fetch already returns a promise
function getUsers() {
  return new Promise((resolve) => {
    resolve(fetch('/api/users'));
  });
}

// Just do this
function getUsers() {
return fetch('/api/users');
}

Swallowing errors by forgetting .catch or try/catch at the top level. Node.js will warn about unhandled promise rejections and will eventually crash on them.

Practice async patterns interactively at CodeUp — working through the exercises will make error propagation and parallel execution click much faster than reading docs.

Ad 728x90