Last updated: 2026-09-14

Asynchronous Programming — Promises

Promises were introduced into the JavaScript language to solve a specific, well-understood problem: callback hell. Where callbacks force you to nest function inside function to express a sequence of asynchronous steps, Promises let you chain them in a flat, linear structure that reads top to bottom — the same way synchronous code does.

A Promise does not change how Node.js executes asynchronous code under the hood. The event loop still works exactly the way it always has. What a Promise changes is how you, as the developer, write and reason about that code.


What Is a Promise?

A Promise is an object that represents the eventual result of an asynchronous operation. When you create a Promise, you do not yet have the result — you have a placeholder that will eventually settle into either a successful result or a failure.

A Promise exists in one of three states:

StateMeaning
PendingThe operation has not completed yet — the initial state
FulfilledThe operation completed successfully and produced a value
RejectedThe operation failed and produced a reason (typically an Error)

A Promise can only transition once — from pending to either fulfilled or rejected. Once it settles, it stays settled. It cannot go from fulfilled back to pending, or from rejected to fulfilled. This is what makes Promises reliable: a settled Promise's outcome never changes.


Creating a Promise

A Promise is created with the Promise constructor, which takes a function called the executor. The executor receives two functions as arguments — resolve and reject — which you call to settle the Promise.

const promise = new Promise((resolve, reject) => {
  const success = true;

  if (success) {
    resolve('Operation completed');
  } else {
    reject(new Error('Operation failed'));
  }
});

In practice, you rarely construct Promises manually like this for everyday application code — most of the time you are consuming Promises that libraries and Node.js APIs already return. Manual construction is mostly used when wrapping an older callback-based function to make it Promise-based.

Wrapping a Callback-Based Function

const fs = require('fs');

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

readFilePromise('data.txt')
  .then((data) => console.log('File contents:', data))
  .catch((err) => console.error('Failed to read file:', err.message));

This pattern — wrapping a Node.js error-first callback function so it returns a Promise — is common enough that Node.js provides a built-in utility for it, covered later in this topic.


Consuming a Promise — .then() and .catch()

Once you have a Promise, you consume its result using .then() for success and .catch() for failure.

fetchUserData(userId)
  .then((user) => {
    console.log('User:', user);
  })
  .catch((err) => {
    console.error('Failed to fetch user:', err.message);
  });

.then() registers a callback that runs when the Promise fulfils. .catch() registers a callback that runs when the Promise rejects. These callbacks are not called synchronously — they always run as microtasks, after the current synchronous code finishes, regardless of whether the Promise was already settled when .then() was called.

console.log('1');

Promise.resolve('done').then((result) => console.log('2:', result));

console.log('3');

// Output:
// 1
// 3
// 2: done

Even though the Promise resolves immediately, the .then() callback still runs after the synchronous code completes — this is the microtask behaviour covered in the event loop topic.

.finally()

.finally() registers a callback that runs regardless of whether the Promise fulfilled or rejected. It is useful for cleanup work that needs to happen either way — closing a connection, hiding a loading indicator, releasing a lock.

fetchUserData(userId)
  .then((user) => console.log('User:', user))
  .catch((err) => console.error('Error:', err.message))
  .finally(() => console.log('Request complete'));

Chaining Promises

The defining feature of Promises is that .then() itself returns a new Promise. This means you can chain multiple .then() calls, and each one runs in sequence, after the previous one resolves.

fetchUser(userId)
  .then((user) => fetchOrders(user.id))
  .then((orders) => fetchOrderDetails(orders[0].id))
  .then((details) => {
    console.log('Order details:', details);
  })
  .catch((err) => {
    console.error('Something failed in the chain:', err.message);
  });

This is the direct solution to callback hell. Compare this to the nested callback version of the same sequence from the previous topic — the structure here is flat. Each step reads top to bottom, in the order it actually executes, with no growing indentation.

How Chaining Works

When a function inside .then() returns a value, that value is automatically wrapped in a resolved Promise and passed to the next .then(). When a function inside .then() returns a Promise, the chain waits for that Promise to settle before continuing.

Promise.resolve(1)
  .then((value) => value + 1)        // returns 2 — wrapped automatically
  .then((value) => value * 10)       // returns 20
  .then((value) => {
    console.log(value);              // 20
  });

This automatic wrapping and waiting is what makes chains compose so cleanly — you never need to think about whether the next .then() will run before or after an inner async operation completes. It always waits.

One .catch() Handles the Whole Chain

A single .catch() at the end of a chain catches a rejection from any point earlier in the chain. You do not need a .catch() after every .then().

fetchUser(userId)
  .then((user) => fetchOrders(user.id))      // if this rejects...
  .then((orders) => fetchOrderDetails(orders[0].id))  // ...this is skipped...
  .then((details) => console.log(details))   // ...this is skipped...
  .catch((err) => console.error(err.message)); // ...and this runs

If any step in the chain rejects, every subsequent .then() is skipped, and control jumps directly to the nearest .catch(). This is a significant improvement over callbacks, where every nested level needed its own explicit error check.


Running Promises in Parallel

Chaining handles sequential operations well, but many real-world tasks involve operations that do not depend on each other and can run at the same time. Promises provide static methods on the Promise object specifically for this.

Promise.all()

Runs multiple Promises concurrently and waits for all of them to fulfil. If any one of them rejects, Promise.all() immediately rejects with that error — even if the others are still pending.

const files = ['a.txt', 'b.txt', 'c.txt'];

Promise.all(files.map((file) => readFilePromise(file)))
  .then((contents) => {
    console.log('All files read:', contents); // array, in original order
  })
  .catch((err) => {
    console.error('At least one file failed:', err.message);
  });

This solves the exact problem the manual counter-based approach struggled with in the callbacks topic — Promise.all handles ordering, completion tracking, and failure propagation automatically. The resolved array preserves the original order of the input array, regardless of which Promise actually settled first.

Promise.allSettled()

Like Promise.all(), but it never rejects. It waits for every Promise to settle — whether fulfilled or rejected — and returns an array describing the outcome of each one individually.

Promise.allSettled(files.map((file) => readFilePromise(file)))
  .then((results) => {
    results.forEach((result, index) => {
      if (result.status === 'fulfilled') {
        console.log(`${files[index]}: ${result.value}`);
      } else {
        console.log(`${files[index]} failed: ${result.reason.message}`);
      }
    });
  });

Use Promise.allSettled() when you need the outcome of every operation, even the ones that fail — for example, sending notifications to multiple users where one failure should not prevent the others from being reported.

Promise.race()

Settles as soon as the first Promise settles — whether it fulfils or rejects. The rest continue running in the background but their outcome is ignored by Promise.race() itself.

Promise.race([
  fetchFromPrimaryServer(),
  fetchFromBackupServer(),
]).then((result) => {
  console.log('First response:', result);
});

A common use for Promise.race() is implementing timeouts — racing an operation against a timer that rejects after a fixed duration:

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('Operation timed out')), ms);
  });

  return Promise.race([promise, timeout]);
}

withTimeout(fetchUserData(userId), 5000)
  .then((data) => console.log(data))
  .catch((err) => console.error(err.message));

Promise.any()

Settles as soon as the first Promise fulfils. Unlike Promise.race(), it ignores rejections unless every Promise rejects — in which case it rejects with an AggregateError containing all the individual errors.

Promise.any([
  fetchFromMirror1(),
  fetchFromMirror2(),
  fetchFromMirror3(),
]).then((result) => {
  console.log('First successful response:', result);
}).catch((aggregateError) => {
  console.error('All mirrors failed:', aggregateError.errors);
});

Promise.any() is useful when you have multiple equivalent sources for the same data and you want the fastest successful response, ignoring any sources that happen to fail.


Comparing the Parallel Methods

MethodSettles WhenRejects When
Promise.all()All Promises fulfilAny single Promise rejects
Promise.allSettled()All Promises settle (fulfil or reject)Never
Promise.race()The first Promise settles (fulfil or reject)The first settled Promise is a rejection
Promise.any()The first Promise fulfilsEvery Promise rejects

Converting Callback APIs with util.promisify

Node.js ships a built-in utility, util.promisify, that automatically converts a function following the error-first callback convention into a function that returns a Promise. This removes the need to manually wrap every callback-based function, as shown earlier in this topic.

const fs = require('fs');
const util = require('util');

const readFilePromise = util.promisify(fs.readFile);

readFilePromise('data.txt', 'utf8')
  .then((data) => console.log(data))
  .catch((err) => console.error(err.message));

util.promisify works on any function that follows the standard (error, result) => {} callback signature as its last argument. It is the standard way to bridge older callback-based code into a Promise-based workflow.

Many core Node.js modules also ship Promise-based versions directly, avoiding the need for promisify altogether:

const fs = require('fs/promises');

fs.readFile('data.txt', 'utf8')
  .then((data) => console.log(data))
  .catch((err) => console.error(err.message));

fs/promises is the Promise-native version of the fs module, and is the preferred way to work with the file system using Promises in modern Node.js.


Common Mistakes with Promises

Forgetting to return inside a .then()

fetchUser(userId)
  .then((user) => {
    fetchOrders(user.id); // missing return — chain does not wait for this
  })
  .then((orders) => {
    console.log(orders); // undefined — orders Promise was never returned
  });

If you do not return the inner Promise, the chain does not wait for it — it moves on immediately with whatever the .then() callback implicitly returns (undefined). The fix is straightforward:

fetchUser(userId)
  .then((user) => {
    return fetchOrders(user.id); // chain waits for this
  })
  .then((orders) => {
    console.log(orders); // correctly populated
  });

Nesting .then() calls instead of chaining

fetchUser(userId).then((user) => {
  fetchOrders(user.id).then((orders) => {
    console.log(orders);
  });
});

This reintroduces the nesting problem Promises were meant to solve — it is callback hell with .then() instead of callbacks. If you find yourself nesting .then() calls, you almost always want to flatten them into a chain by returning the inner Promise instead.

Swallowing errors with no .catch()

A Promise chain with no .catch() will fail silently in older environments, or produce an unhandled rejection warning in modern Node.js — and an unhandled rejection will eventually crash the process in newer versions. Always terminate a Promise chain with a .catch().


Why Promises Were Not the Final Word

Promises solve callback hell and make composing asynchronous operations dramatically cleaner. But chains of .then() calls are still not identical to writing synchronous code — control flow with conditionals, loops, and try/catch blocks inside a .then() chain is awkward compared to ordinary JavaScript.

This is the gap that async/await closes — a syntax built directly on top of Promises that allows asynchronous code to be written and read exactly like synchronous code, while still being non-blocking underneath.


Summary

ConceptWhat It Means
PromiseAn object representing the eventual result of an async operation
Pending / Fulfilled / RejectedThe three states a Promise can be in
.then()Registers a callback for when the Promise fulfils; returns a new Promise
.catch()Registers a callback for when the Promise rejects
.finally()Runs regardless of fulfilment or rejection
ChainingReturning a value or Promise inside .then() to sequence operations
Promise.all()Waits for all Promises to fulfil; rejects if any one rejects
Promise.allSettled()Waits for all Promises to settle; never rejects
Promise.race()Settles as soon as the first Promise settles
Promise.any()Settles as soon as the first Promise fulfils
util.promisifyConverts an error-first callback function into a Promise-returning function
fs/promisesThe Promise-native version of the fs module