jsguides

JavaScript Promises: Microtasks, Chaining, and Errors

A Promise represents a value that may be available now, in the future, or never. JavaScript promises are the foundation of modern async programming, and understanding them in depth is essential for writing dependable asynchronous code in Node.js and 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"

Once settled, a Promise’s value is locked in. Calling resolve() or reject() again has no effect. This guarantee simplifies reasoning about async code because you never have to worry about a promise changing its mind halfway through a chain. The constructor is the most direct way to create a promise from scratch.

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);
});

The new Promise constructor wraps a callback that receives resolve and reject. Call resolve(value) to fulfill the promise and reject(error) to reject it. For simple cases where you already have a value or error, the static Promise.resolve() and Promise.reject() methods create pre-settled promises without the ceremony of the constructor:

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

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

Promise.resolve() is particularly useful when a function may return either a promise or a plain value. Wrapping the return in Promise.resolve() normalizes it, so callers always get a thenable. For situations where you need to control a promise’s settlement from outside its executor callback, Promise.withResolvers() gives you a deferred-style interface:

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

The output order reveals a key rule: all synchronous code finishes first, then microtasks run, and only then do macrotasks execute. Even with a setTimeout delay of zero, the microtask queue drains before the timer callback fires. This ordering becomes critical when you share state between synchronous code and promise callbacks:

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

The count variable is still zero when the first console.log runs, even though two .then() callbacks are queued. Both increments wait until all synchronous code finishes. Understanding this ordering is essential before you start chaining promises, because each .then() in a chain creates a new microtask.

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

Each handler transforms the value and passes it to the next step. When a handler returns a promise (like the setTimeout wrapper above), the chain waits for that inner promise to settle before proceeding. This is how sequential async operations stay flat instead of nesting. The flip side is error propagation, which follows the same chain:

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"

When an error is thrown, every .then() in between is skipped until a .catch() handles it. If no .catch() exists, the rejection becomes unhandled, which can crash Node.js or produce console warnings in browsers. The placement of .catch() determines which errors it covers:

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. Placing an error handler early lets the chain continue after a known failure, while a final .catch() is a safety net for anything that slipped through.

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. Knowing which combinator to use is important, but so is knowing what goes wrong when you write promise chains incorrectly. Here are three common mistakes.

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

Without return, the .then() callback produces undefined, which becomes the input to the next handler. This silent behavior is one of the most frequent sources of confusion in promise-based code. A separate issue is mixing async/await with .then() chains in the same function:

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 })));
}

Both styles work independently, but combining them in the same function makes the control flow harder to trace. Pick one and stay with it throughout a given function. A third mistake, and the most dangerous, is simply not handling rejections at all:

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. For more specialized use cases, you can extend the Promise class itself.

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.

Choosing the right combinator

Promise helpers solve different coordination problems, so it helps to be precise about what you want. Use Promise.all() when every task must succeed before you continue. Use Promise.allSettled() when you want to collect results from a mixed batch and inspect each outcome afterward. Use Promise.race() when the first settled promise should decide the result, and Promise.any() when any successful promise is enough.

That choice affects more than control flow. It shapes how your program reports partial failure, how it handles retry logic, and how much work it keeps around after the first result arrives. When the wrong combinator is used, the code can look fine at a glance but behave awkwardly under load or failure.

Timeouts and Cleanup

Promises do not cancel themselves, so timeout handling usually means pairing the promise with another signal or timer. That pattern keeps long-running work from hanging forever. It also gives the caller a clear failure path when a service is too slow or unavailable. A timeout is not just a guard rail. It is part of the contract.

Cleanup matters just as much as the timeout itself. If you start timers, listeners, or other resources while waiting for a promise, make sure they are cleared when the promise settles. A tidy promise chain should leave behind only the final result or error, not a trail of work that no longer matters.

Keeping chains readable

Long chains become easier to follow when each step has a single purpose. One step can fetch data, the next can validate it, and the next can shape it for the caller. When a chain starts doing too much in one callback, the flow gets harder to scan and harder to test. Small named functions can help a lot here.

It also helps to think about what each callback returns. Returning a promise keeps the chain alive, while returning a plain value resolves the next step immediately. Being clear about those handoffs makes async code much less surprising for the next person who has to change it.

Error Surfaces

Promise errors are easiest to manage when each layer knows what it owns. A low-level function should reject with enough detail to explain what failed, while a higher-level caller decides how to present that failure to the user. That separation keeps the code honest and avoids burying the original problem behind a generic message too early.

If you are not sure where an error belongs, look at who can recover from it. The closer a function is to the source of the failure, the more specific its message can be. The farther up the stack you go, the more the code should focus on user-facing behavior and fallback choices.

Promise debugging habits

When a promise chain misbehaves, the first step is usually to find out where the chain stopped. Logging the value at each handoff can quickly show whether a promise resolved, rejected, or returned something unexpected. That kind of tracing is often enough to isolate a bug without stepping through every line in a debugger.

It also helps to give each async step a name. A small helper that fetches data, a second helper that validates it, and a third helper that formats it are easier to inspect than one long anonymous chain. Named steps make both error handling and code review much calmer.

Keeping chains short

Long chains are easiest to understand when each callback does one job and hands control back immediately. If a chain starts to carry a lot of branching logic, move the logic into named helpers and keep the promise chain focused on coordination. That makes the async flow easier to scan and the individual pieces easier to test.

Short chains are also easier to adjust when requirements change. A small helper can be replaced, retried, or reordered without making the rest of the sequence hard to follow. In practice, that often matters more than squeezing every last step into one fluent expression.

See Also