JavaScript Promises

by Kuligaposten 2026-08-27

A complete guide to JavaScript Promises, from the basics of then/catch to newer additions like Promise.any, Promise.withResolvers, and Promise.try.

Understanding JavaScript Promises

A Promise represents a value that may not be available yet, but will be at some point in the future, either successfully (fulfilled) or unsuccessfully (rejected). Promises are the foundation of modern asynchronous JavaScript and power async/await under the hood. This post walks through the fundamentals and covers the newer additions to the Promise API, including Promise.try().

The Three States of a Promise

A promise is always in one of three states:

  • Pending: The initial state, neither fulfilled nor rejected.
  • Fulfilled: The operation completed successfully.
  • Rejected: The operation failed.

Once a promise settles (fulfills or rejects), it stays that way permanently, it can never change state again.

Creating a Promise

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

  setTimeout(() => {
    if (success) {
      resolve("Data loaded");
    } else {
      reject(new Error("Failed to load data"));
    }
  }, 1000);
});

The executor function runs immediately, and you call resolve(value) or reject(reason) when the async work finishes.

Consuming Promises: then, catch, finally

promise
  .then((result) => {
    console.log(result); // 'Data loaded'
  })
  .catch((error) => {
    console.error(error.message);
  })
  .finally(() => {
    console.log("Done, regardless of outcome");
  });
  • then(onFulfilled, onRejected): Registers callbacks for fulfillment and/or rejection.
  • catch(onRejected): Shorthand for .then(null, onRejected).
  • finally(onFinally): Runs regardless of outcome, useful for cleanup like hiding a loading spinner.

Chaining Promises

Each .then() returns a new promise, which enables chaining instead of nested callbacks ("callback hell"):

fetch("/api/user/1")
  .then((response) => response.json())
  .then((user) => fetch(`/api/posts?userId=${user.id}`))
  .then((response) => response.json())
  .then((posts) => console.log(posts))
  .catch((error) => console.error("Something went wrong:", error));

A single .catch() at the end handles errors from any step in the chain.

async/await

async/await is syntactic sugar over promises that lets asynchronous code read like synchronous code:

async function getUserPosts(userId) {
  try {
    const userResponse = await fetch(`/api/user/${userId}`);
    const user = await userResponse.json();

    const postsResponse = await fetch(`/api/posts?userId=${user.id}`);
    const posts = await postsResponse.json();

    return posts;
  } catch (error) {
    console.error("Something went wrong:", error);
  }
}

An async function always returns a promise, and await pauses execution until the awaited promise settles.

Combinators: Running Multiple Promises

Promise.all()

Waits for all promises to fulfill, or rejects as soon as any one rejects.

const [users, posts, comments] = await Promise.all([
  fetch("/api/users").then((r) => r.json()),
  fetch("/api/posts").then((r) => r.json()),
  fetch("/api/comments").then((r) => r.json()),
]);

Use this when you need every result and can't proceed if even one fails.

Promise.allSettled()

Waits for every promise to settle, regardless of success or failure, and never rejects.

const results = await Promise.allSettled([
  fetch("/api/a"),
  fetch("/api/b"),
  fetch("/api/c"),
]);

results.forEach((result) => {
  if (result.status === "fulfilled") {
    console.log("Success:", result.value);
  } else {
    console.log("Failed:", result.reason);
  }
});

Use this when partial failure is acceptable and you want a full report.

Promise.race()

Settles as soon as the first promise settles, whether fulfilled or rejected.

const timeout = (ms) =>
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Timeout")), ms),
  );

const data = await Promise.race([fetch("/api/slow"), timeout(5000)]);

A classic use case: racing a request against a timeout.

Promise.any()

Settles as soon as the first promise fulfills, and only rejects if all promises reject (with an AggregateError).

try {
  const first = await Promise.any([
    fetch("/mirror1/data.json"),
    fetch("/mirror2/data.json"),
    fetch("/mirror3/data.json"),
  ]);
  console.log("First successful response:", first);
} catch (error) {
  console.error("All mirrors failed:", error.errors); // AggregateError
}

Useful for querying redundant sources and taking whichever answers first.

Newer Additions

Promise.withResolvers()

Added in ES2024, Promise.withResolvers() exposes the resolve and reject functions alongside the promise itself, without needing to declare let variables and assign them inside the executor.

// Before
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

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

// Use resolve/reject from outside the executor, e.g. in an event handler
button.addEventListener("click", () => resolve("clicked"));
promise.then((value) => console.log(value));

This is handy for bridging event-based APIs into promises.

Promise.try()

Promise.try() runs a function and wraps the outcome in a promise, whether that function returns a plain value, throws synchronously, or returns a promise. It normalizes sync and async functions into a single promise-based flow.

function mightThrowSync(x) {
  if (x < 0) throw new Error("Negative value");
  return x * 2;
}

Promise.try(() => mightThrowSync(-1))
  .then((result) => console.log(result))
  .catch((error) => console.error("Caught:", error.message));

Without Promise.try(), calling a function that might throw synchronously requires a try/catch before you even get a promise to chain onto:

// Without Promise.try()
let result;
try {
  result = Promise.resolve(mightThrowSync(-1));
} catch (error) {
  result = Promise.reject(error);
}
result.catch((error) => console.error("Caught:", error.message));

// With Promise.try()
Promise.try(() => mightThrowSync(-1)).catch((error) =>
  console.error("Caught:", error.message),
);

It's especially useful when writing utility functions that accept a callback which may be sync or async, and you want consistent promise-based error handling either way.

Common Pitfalls

  1. Forgetting to return inside a .then() chain, which breaks the chain and causes the next .then() to receive undefined:
// Bug: missing return
fetch("/api/data")
  .then((response) => {
    response.json(); // not returned!
  })
  .then((data) => console.log(data)); // undefined
  1. Swallowing errors by omitting .catch() or a try/catch around await, which can lead to unhandled promise rejections.

  2. Using Promise.all() when partial failure is fine, which causes the whole batch to fail as soon as one item rejects. Reach for Promise.allSettled() instead when you need every result reported.

  3. Mixing await inside a loop unnecessarily, which serializes work that could run concurrently:

// Slow: sequential
for (const id of ids) {
  const data = await fetchData(id);
  process(data);
}

// Fast: concurrent
const results = await Promise.all(ids.map((id) => fetchData(id)));
results.forEach(process);

Conclusion

Promises turned asynchronous JavaScript from a tangle of nested callbacks into composable, readable code. The combinator methods (all, allSettled, race, any) give you precise control over how multiple async operations interact, while newer additions like Promise.withResolvers() and Promise.try() smooth over long-standing rough edges in the API. Together with async/await, they form the backbone of how modern JavaScript handles asynchrony.

Back to Home