JavaScript Promises

A Promise is an object that represents the eventual result of an asynchronous operation — a value that does not exist yet, but will (or an error that will occur instead). Before Promises, asynchronous JavaScript relied on nested callbacks that quickly became hard to read and reason about, a pattern often nicknamed callback hell. Promises fix this by giving asynchronous code a predictable shape: you get an object back immediately, and you attach handlers that run once the underlying work finishes. They are also the foundation that async/await is built on top of, so understanding Promises deeply is essential to understanding modern asynchronous JavaScript.

Overview / How Promises Work

Every Promise exists in exactly one of three states at any moment: pending (the initial state, before the operation finishes), fulfilled (the operation succeeded and the promise now has a value), or rejected (the operation failed and the promise has a reason, usually an Error). Once a promise moves from pending to fulfilled or rejected it is said to be settled, and it can never change state again — a settled promise is immutable. This guarantee is what makes promises reliable: you never have to worry about a callback firing twice, or a value silently changing after you’ve already read it.

You create a promise with the new Promise() constructor, passing it an executor function. The executor receives two functions, conventionally named resolve and reject, which you call to settle the promise. Critically, the executor runs synchronously, immediately, the moment you call new Promise(...) — it does not wait for any event loop tick. What is asynchronous is usually not the executor itself, but the work it kicks off (a timer, a network request, a file read) before it eventually calls resolve or reject. If the executor throws an exception synchronously, the promise is automatically rejected with that exception, even if reject was never explicitly called.

The real power of promises comes from .then(), .catch(), and .finally(). Each of these methods returns a brand-new promise, which is what makes chaining possible: promise.then(a).then(b).then(c) works because every step hands its result off to the next. If a .then() handler returns a plain value, the next promise in the chain is fulfilled with that value. If it returns another promise (or any thenable object), the chain automatically waits for that inner promise to settle before continuing — promises never nest, they always flatten into a single chain. If a handler throws, or if there is simply no handler for the current stage, the rejection skips forward through the chain until it reaches a .catch() (or a .then() that supplies a second, rejection-handling argument).

Under the hood, callbacks passed to .then(), .catch(), and .finally() are never invoked synchronously, even if the promise is already settled at the moment you attach them. Instead, they are scheduled as microtasks. The JavaScript engine keeps a microtask queue that is drained completely after the current synchronous block of code finishes, but before the event loop moves on to the next macrotask (a setTimeout callback, a network event, a UI event, and so on). This is why promise callbacks always run “later,” but sooner than timers — a distinction that trips up a lot of developers, and one we will see directly in the Under the Hood example below.

Syntax

const promise = new Promise((resolve, reject) => {
  const success = true;
  if (success) {
    resolve('result value');
  } else {
    reject(new Error('failure reason'));
  }
});

promise
  .then(result => {
    // runs when the promise resolves
  })
  .catch(error => {
    // runs when the promise rejects
  })
  .finally(() => {
    // always runs, resolved or rejected
  });
Piece Purpose
new Promise(executor) Creates a promise; the executor runs immediately and must call resolve(value) or reject(reason).
.then(onFulfilled, onRejected) Registers callbacks for success and, optionally, failure; returns a new promise.
.catch(onRejected) Shorthand for .then(undefined, onRejected) — catches rejections from anywhere earlier in the chain.
.finally(onFinally) Runs regardless of outcome; useful for cleanup. Does not receive the value or reason.
Promise.resolve(value) Returns an already-fulfilled promise (or the value itself, unwrapped, if it is already a promise).
Promise.reject(reason) Returns an already-rejected promise.
Promise.all(iterable) Waits for every promise to fulfill; rejects immediately if any single one rejects.
Promise.allSettled(iterable) Waits for every promise to settle, successful or not, and reports every outcome.
Promise.race(iterable) Settles as soon as the first promise settles, whether fulfilled or rejected.
Promise.any(iterable) Fulfills as soon as the first promise fulfills; rejects only if every promise rejects.

Examples

Example 1: Creating and consuming a basic Promise

function checkAge(age) {
  return new Promise((resolve, reject) => {
    if (age >= 18) {
      resolve(`Access granted (age ${age})`);
    } else {
      reject(new Error(`Access denied (age ${age})`));
    }
  });
}

checkAge(20)
  .then(message => console.log(message))
  .catch(error => console.log('Error:', error.message));

checkAge(15)
  .then(message => console.log(message))
  .catch(error => console.log('Error:', error.message));

Output:

Access granted (age 20)
Error: Access denied (age 15)

checkAge returns a new promise each time it is called. Inside the executor, resolve or reject is called synchronously based on the age check, but the .then/.catch handlers still run as microtasks afterward, not immediately. Because the first call’s success handler was scheduled slightly before the second call’s rejection eventually reaches its .catch, the granted message logs first, followed by the denial message.

Example 2: Chaining transformations

function delay(ms, value) {
  return new Promise(resolve => setTimeout(() => resolve(value), ms));
}

delay(100, 5)
  .then(num => {
    console.log('Step 1:', num);
    return num * 2;
  })
  .then(num => {
    console.log('Step 2:', num);
    return num * 3;
  })
  .then(num => {
    console.log('Step 3 (final):', num);
  })
  .catch(err => console.log('Caught:', err.message));

Output:

Step 1: 5
Step 2: 10
Step 3 (final): 30

This is the pattern that replaces nested callbacks. delay wraps setTimeout in a promise so it can be chained. Each .then() receives the value returned by the previous one, transforms it, and passes it along — 5 becomes 10, then 30 — producing a flat, readable pipeline instead of a pyramid of nested callbacks.

Example 3: Running independent promises concurrently with Promise.all

function fetchUser(id) {
  return new Promise(resolve => {
    setTimeout(() => resolve({ id, name: `User${id}` }), 50);
  });
}

function fetchPosts(userId) {
  return new Promise(resolve => {
    setTimeout(() => resolve([`Post by ${userId} #1`, `Post by ${userId} #2`]), 30);
  });
}

async function loadDashboard() {
  try {
    const [user, posts] = await Promise.all([fetchUser(1), fetchPosts(1)]);
    console.log('User:', user);
    console.log('Posts:', posts);
  } catch (err) {
    console.log('Dashboard failed:', err.message);
  }
}

loadDashboard();

Output:

User: { id: 1, name: 'User1' }
Posts: [ 'Post by 1 #1', 'Post by 1 #2' ]

Promise.all starts both asynchronous calls at the same time instead of waiting for one before starting the other, so the total wait is roughly the length of the slower operation (50ms) rather than the sum of both (80ms). If either promise had rejected, the whole Promise.all would reject immediately with that reason, which is why the try/catch around the await is important.

Under the Hood: The Microtask Queue vs. the Macrotask Queue

console.log('Start');

setTimeout(() => console.log('Timeout callback'), 0);

Promise.resolve()
  .then(() => console.log('Promise 1'))
  .then(() => console.log('Promise 2'));

console.log('End');

Output:

Start
End
Promise 1
Promise 2
Timeout callback

This example reveals exactly how the engine schedules work. The synchronous code runs first, top to bottom: 'Start' logs, setTimeout registers its callback in the macrotask queue for “as soon as possible after 0ms,” the .then() calls register their callbacks in the microtask queue, and 'End' logs. Only once the call stack is completely empty does the engine drain the entire microtask queue — running 'Promise 1' and then 'Promise 2' — before it is allowed to pick up the next macrotask and run the setTimeout callback. This is true even though the timer was given a delay of 0: microtasks (promises) always win over macrotasks (timers, I/O) once the synchronous code has finished.

Common Mistakes

Mistake 1: Forgetting to return inside a .then() chain

function getUser(id) {
  return Promise.resolve({ id, name: 'Ada' });
}

getUser(1).then(user => {
  getUser(user.id + 1); // missing return
}).then(nextUser => {
  console.log('Next user:', nextUser);
});

Output:

Next user: undefined

The inner call to getUser kicks off a promise, but because the arrow function has a block body and no return statement, the outer .then() handler itself resolves with undefined — the next .then() never gets the inner promise’s value at all. The fix is simply to return the inner promise so the chain waits for it:

function getUser(id) {
  return Promise.resolve({ id, name: 'Ada' });
}

getUser(1).then(user => {
  return getUser(user.id + 1);
}).then(nextUser => {
  console.log('Next user:', nextUser);
});

Output:

Next user: { id: 2, name: 'Ada' }

Mistake 2: Not handling rejections

async function loadConfig() {
  const config = await Promise.reject(new Error('Config file not found'));
  console.log(config);
}

loadConfig();

Output:

Nothing is ever printed to the console here. The awaited promise rejects, which makes loadConfig()‘s own returned promise reject too — but since nothing calls .catch() on it, Node.js reports an unhandled promise rejection (visible as a warning on the error stream) instead of a clean, readable error in your program’s own output. Always pair an async function’s await calls with a try/catch, or attach a .catch() when calling the function:

async function loadConfig() {
  try {
    const config = await Promise.reject(new Error('Config file not found'));
    console.log(config);
  } catch (err) {
    console.log('Failed to load config:', err.message);
  }
}

loadConfig();

Output:

Failed to load config: Config file not found

Best Practices

  • Always return a value or promise from inside a .then() callback when you need the chain to keep waiting on it — a missing return is the single most common promise bug.
  • Always terminate a promise chain with a .catch(), or wrap await calls in try/catch; an unhandled rejection is a silent bug waiting to surface in production.
  • Prefer async/await for sequential logic — it reads like synchronous code — but reach for .then() chains or combinators when you’re transforming a stream of values functionally.
  • Use Promise.all() to run independent asynchronous operations concurrently instead of awaiting them one after another, which needlessly serializes work that doesn’t depend on itself.
  • Use Promise.allSettled() instead of Promise.all() when you need every result, successes and failures alike, rather than failing fast on the first rejection.
  • Avoid the “Promise constructor antipattern”: never wrap a function that already returns a promise in a redundant new Promise(...) — just return the existing promise directly.
  • Remember that a promise’s executor runs synchronously and immediately; don’t rely on new Promise(...) itself to defer work to a later tick.
  • Use .finally() for cleanup logic (hiding a spinner, closing a connection) that must run whether the operation succeeded or failed.

Practice Exercises

  1. Write a function rollDice() that returns a promise. It should resolve with a random integer from 1 to 6 after a short setTimeout, but reject with an Error if the random roll is exactly 1 (simulating a 1-in-6 failure). Consume it with .then()/.catch() and log the outcome.
  2. Using Promise.all(), write code that fetches three separate pieces of data (simulate each with a small setTimeout-based promise returning a different string) and logs all three results together once they’re all ready.
  3. Rewrite your solution to Exercise 2 using async/await instead of .then() chaining, wrapped in a try/catch so a rejection from any one of the three doesn’t crash the program silently.

Summary

  • A Promise represents a future value and is always in one of three states: pending, fulfilled, or rejected; once settled it never changes again.
  • The executor passed to new Promise() runs synchronously; resolve/reject settle the promise, often after some asynchronous work completes.
  • .then(), .catch(), and .finally() each return a new promise, which is what enables chaining; returning a value or promise from a handler controls what the next link in the chain receives.
  • Promise callbacks run as microtasks, which always execute before the next macrotask (like a setTimeout), even one scheduled with a delay of 0.
  • Promise.all(), Promise.allSettled(), Promise.race(), and Promise.any() combine multiple promises with different waiting and failure semantics.
  • async/await is syntactic sugar over promises — understanding promises is what makes async/await behavior predictable rather than magical.
  • The most common real-world bugs are forgetting to return inside a .then() and forgetting to handle a rejection at all.