Promises

Difficulty: Intermediate

A Promise is an object representing the eventual completion or failure of an asynchronous operation. It is a container for a future value. Promises solve the fundamental problems of callbacks - inversion of control, callback hell, and inconsistent error handling - by providing a standardized, composable way to handle async results. Every promise is in one of three states: pending (initial state, neither fulfilled nor rejected), fulfilled (the operation completed successfully), or rejected (the operation failed). Once a promise settles (becomes fulfilled or rejected), it stays in that state permanently - this immutability is a core guarantee.

You create a promise with new Promise((resolve, reject) => { ... }). The executor function receives two functions: resolve(value) to fulfill the promise with a value, and reject(reason) to reject it with an error. The executor runs synchronously and immediately. If the executor throws an error, the promise is automatically rejected. Only the first call to resolve or reject takes effect - subsequent calls are ignored, which prevents the double-callback problem inherent in callback-based APIs.

The .then() method is the primary way to consume a promise. It takes two optional arguments: an onFulfilled callback and an onRejected callback. More importantly, .then() returns a new promise, enabling chaining. The value returned from a .then() callback becomes the resolved value of the new promise. If you return a promise from .then(), the chain waits for that promise to settle before continuing. This is the key mechanism that makes promise chains work - each .then() creates a new promise that depends on the previous one.

.catch() is shorthand for .then(undefined, onRejected). It handles rejections from anywhere earlier in the chain. Errors propagate down the chain until they find a .catch() - skipping all .then() handlers along the way. This is similar to synchronous try/catch: code runs until an error occurs, then execution jumps to the nearest catch block. .finally() runs regardless of whether the promise was fulfilled or rejected, making it ideal for cleanup operations like hiding loading spinners or closing database connections.

Promise chaining is the pattern of connecting multiple .then() calls to form a sequence of asynchronous operations. Each step in the chain can transform the value, perform side effects, or initiate new async operations. If a .then() callback returns a regular value, the next .then() receives it immediately. If it returns a promise, the chain pauses until that promise settles. This linear, top-to-bottom flow is dramatically more readable than nested callbacks.

Converting callback-based APIs to promises is a common task. You wrap the callback-based function in a new Promise, calling resolve on success and reject on error. Node.js provides util.promisify() as a built-in utility for this. Most modern APIs provide promise-based alternatives natively, but understanding the manual conversion pattern is important for working with legacy code and libraries that only offer callbacks.

Code examples

Creating and consuming promises

// Creating a promise
function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    console.log('Fetching user...');
    setTimeout(() => {
      if (userId <= 0) {
        reject(new Error('Invalid user ID'));
        return;
      }
      resolve({ id: userId, name: 'Alice', role: 'admin' });
    }, 100);
  });
}

// Consuming with .then() and .catch()
fetchUserData(1)
  .then(user => {
    console.log('User:', user.name);
    console.log('Role:', user.role);
  })
  .catch(err => {
    console.log('Error:', err.message);
  })
  .finally(() => {
    console.log('Request complete');
  });

// Error case
fetchUserData(-1)
  .then(user => {
    console.log('This will not run');
  })
  .catch(err => {
    console.log('Caught:', err.message);
  });

fetchUserData returns a promise. On success, .then() receives the resolved value. On failure, .catch() handles the rejection. .finally() always runs regardless of outcome. Note that 'Fetching user...' prints immediately because the executor is synchronous.

Promise chaining - sequential operations

function getUser(id) {
  return new Promise(resolve => {
    setTimeout(() => resolve({ id, name: 'Bob', departmentId: 5 }), 50);
  });
}

function getDepartment(deptId) {
  return new Promise(resolve => {
    setTimeout(() => resolve({ id: deptId, name: 'Engineering', managerId: 99 }), 50);
  });
}

function getManager(managerId) {
  return new Promise(resolve => {
    setTimeout(() => resolve({ id: managerId, name: 'Carol', title: 'VP' }), 50);
  });
}

// Clean chain - no nesting
getUser(1)
  .then(user => {
    console.log('Employee:', user.name);
    return getDepartment(user.departmentId);  // return promise
  })
  .then(dept => {
    console.log('Department:', dept.name);
    return getManager(dept.managerId);        // return promise
  })
  .then(manager => {
    console.log('Manager:', manager.name, '-', manager.title);
  })
  .catch(err => {
    console.log('Something failed:', err.message);
  });

Each .then() returns the promise from the next async call. The chain waits for each promise to resolve before proceeding. If any promise rejects, execution jumps to .catch(), skipping all remaining .then() handlers. Compare this to the deeply nested callback version.

Error propagation in promise chains

function step1() {
  return Promise.resolve('Step 1 done');
}

function step2() {
  return Promise.reject(new Error('Step 2 failed!'));
}

function step3() {
  return Promise.resolve('Step 3 done');
}

// Error at step2 skips step3 and goes to catch
step1()
  .then(result => {
    console.log(result);
    return step2();
  })
  .then(result => {
    console.log(result);      // skipped
    return step3();
  })
  .then(result => {
    console.log(result);      // skipped
  })
  .catch(err => {
    console.log('Caught:', err.message);
  })
  .then(() => {
    console.log('Chain continues after catch');
  });

// Throwing inside .then() also triggers catch
Promise.resolve('data')
  .then(val => {
    throw new Error('Thrown in then');
  })
  .catch(err => {
    console.log('Caught throw:', err.message);
  });

When step2 rejects, the chain skips all subsequent .then() handlers until it finds .catch(). After .catch() handles the error, the chain continues normally - the .then() after catch runs. Throwing inside .then() has the same effect as returning a rejected promise.

Converting callbacks to promises

// Original callback-based function
function readFileCallback(filename, callback) {
  setTimeout(() => {
    if (filename === 'bad.txt') {
      callback(new Error('File not found'));
      return;
    }
    callback(null, `Contents of ${filename}`);
  }, 100);
}

// Manual promise wrapper
function readFilePromise(filename) {
  return new Promise((resolve, reject) => {
    readFileCallback(filename, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

// Generic promisify utility
function promisify(fn) {
  return function(...args) {
    return new Promise((resolve, reject) => {
      fn(...args, (err, result) => {
        if (err) reject(err);
        else resolve(result);
      });
    });
  };
}

const readFile = promisify(readFileCallback);

// Use the promisified version
readFile('data.txt')
  .then(data => console.log(data))
  .catch(err => console.log(err.message));

readFile('bad.txt')
  .then(data => console.log(data))
  .catch(err => console.log(err.message));

readFilePromise wraps the callback-based API in a promise. The generic promisify function works for any error-first callback function. Node.js provides util.promisify() which does the same thing. This pattern is essential when integrating legacy callback APIs into modern promise-based code.

Key points

Concepts covered

Promise, then, catch, finally, promise chaining, error propagation, promise states