Promises in Depth

· 5 min read · Updated March 16, 2026 · intermediate
promises async javascript asynchronous

A Promise represents a value that may be available now, in the future, or never. Understanding Promises thoroughly is essential for writing robust asynchronous JavaScript, whether you are working in Node.js or the browser.

Promise States and Fates

A Promise can be in one of three states:

  • Pending: The initial state — the operation has not completed yet
  • Fulfilled: The operation completed successfully and the Promise has a resolved value
  • Rejected: The operation failed and the Promise has a reason (error)

Once a Promise settles (either fulfilled or rejected), it becomes immutable. You cannot transition back to pending or change the outcome:

const promise = new Promise((resolve, reject) => {
  resolve("first"); // This happens
  resolve("second"); // Ignored — already settled
  reject("error");   // Ignored — already settled
});

promise.then(console.log); // "first"

This immutability is a feature, not a bug. It ensures that Promise-based code is predictable regardless of timing.

Creating Promises

The most common way to create a Promise is with the constructor:

const promise = new Promise((resolve, reject) => {
  // Async operation here
  setTimeout(() => {
    const success = Math.random() > 0.5;
    if (success) {
      resolve({ id: 1, name: "Alice" });
    } else {
      reject(new Error("Operation failed"));
    }
  }, 1000);
});

You can also create already-resolved or rejected Promises using static methods:

const resolved = Promise.resolve(42);
const rejected = Promise.reject(new Error("Failed"));

A lesser-known pattern is Promise.withResolvers(), which gives you the resolve and reject functions separately from the Promise itself:

const { promise, resolve, reject } = Promise.withResolvers();

This is useful when you need to pass the resolver functions to somewhere other than the callback scope.

The Microtask Queue

This is where many developers get caught out. Promises are not executed immediately — they go through the microtask queue, which runs after the current synchronous code finishes but before the next macrotask (like setTimeout):

console.log("1. synchronous");

Promise.resolve().then(() => console.log("2. microtask"));

setTimeout(() => console.log("3. macrotask"), 0);

console.log("4. synchronous end");

// Output:
// 1. synchronous
// 4. synchronous end
// 2. microtask
// 3. macrotask

This matters when you mix synchronous code, Promises, and callbacks:

let count = 0;

function increment() {
  count++;
}

Promise.resolve().then(increment);
Promise.resolve().then(increment);

console.log(count); // 0 — microtasks have not run yet

// Later, after all synchronous code:
console.log(count); // 2

Chaining and Return Values

Each call to .then(), .catch(), or .finally() returns a new Promise. This enables chaining, but the returned Promise resolves to whatever the handler returns:

Promise.resolve(1)
  .then(x => x + 1)      // returns 2
  .then(x => {
    return new Promise(resolve => setTimeout(() => resolve(x * 2), 100));
  })                     // returns Promise resolving to 4
  .then(console.log);    // logs 4

If a handler throws an error or returns a rejected Promise, the chain propagates that rejection:

Promise.resolve("start")
  .then(() => {
    throw new Error("Something went wrong");
  })
  .then(() => console.log("This will not run"))
  .catch(err => console.error(err.message)); // "Something went wrong"

Error Handling Patterns

The .catch() method is shorthand for .then(null, onRejected). However, placement matters:

// Pattern 1: Catch at the end — catches all errors
fetch("/api/users")
  .then(res => res.json())
  .then(users => {
    // process users
  })
  .catch(err => {
    console.error(err); // Catches errors from both then() handlers
  });

// Pattern 2: Catch early — handle errors locally
fetch("/api/users")
  .then(
    res => res.json(),      // onFulfilled
    err => {               // onRejected
      console.error(err);
      return [];           // Recover with empty array
    }
  )
  .then(users => {
    // Continues even if fetch failed
  });

The second pattern is useful when you want to handle different errors in different ways or recover from specific failures.

Promise.all(), Promise.race(), and Promise.allSettled()

These static methods help coordinate multiple Promises:

const urls = ["/api/users", "/api/posts", "/api/comments"];

// Wait for all to fulfill — fails fast if any rejects
const results = await Promise.all(urls.map(url => fetch(url).then(r => r.json())));

// Wait for the first to settle (fulfill or reject)
const first = await Promise.race(urls.map(url => fetch(url)));

// Wait for all to settle — never fails
const allResults = await Promise.allSettled(urls.map(url => fetch(url)));

allResults.forEach((result, index) => {
  if (result.status === "fulfilled") {
    console.log(`URL ${index} succeeded:`, result.value);
  } else {
    console.log(`URL ${index} failed:`, result.reason);
  }
});

Use Promise.allSettled() when you need results from all Promises regardless of individual failures — it is the safest choice for bulk operations.

Common Pitfalls

Forgetting to Return

A common mistake is forgetting to return from .then() handlers:

// Bug: Returns undefined, not the doubled value
Promise.resolve(5)
  .then(x => {
    x * 2; // No return!
  })
  .then(console.log); // undefined

// Fix: Use return
Promise.resolve(5)
  .then(x => {
    return x * 2;
  })
  .then(console.log); // 10

Mixing async/await with .then()

Both patterns work, but mixing them adds confusion:

// Stick to one style
async function getData() {
  const a = await fetch("/api/a");
  const b = await fetch("/api/b");
  return { a, b };
}

// Or use .then() chains
function getData() {
  return fetch("/api/a")
    .then(a => fetch("/api/b")
    .then(b => ({ a, b })));
}

Not Handling Rejections

Unhandled Promise rejections can crash your application in Node.js or cause console warnings in browsers:

// This will eventually cause problems
Promise.reject(new Error("Oops"));

// Always handle rejections
Promise.reject(new Error("Oops")).catch(() => {
  // Handled
});

Modern Node.js will terminate the process if you do not handle rejections, so always use .catch() or try/catch with async/await.

Advanced: Promise Subclassing

You can extend Promise with custom behavior:

class DelayPromise extends Promise {
  static delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

async function example() {
  await DelayPromise.delay(1000);
  console.log("One second later");
}

This pattern is useful for adding convenience methods specific to your application needs.

See Also