Module: JavaScript
JavaScript·080·9 MIN READ

080: Promises and Promise Combinators

TOPICS COVERED: Promises and Promise Combinators

Learning outcomes

By the end, you can:

  • identify the pending, fulfilled, and rejected states;
  • consume Promises with then, catch, and finally;
  • build a flat chain by returning values and Promises;
  • create a Promise only when adapting work that does not already return one;
  • distinguish fulfilled from resolved and a Promise from a thread;
  • locate floating Promises and unhandled rejections.

Retrieval warm-up

  1. What runs first: current synchronous code or a callback passed to .then() on an already-fulfilled Promise?
  2. Why can a zero-delay timer still run late?
  3. What does "run to completion" mean?

Vocabulary

  • Promise: “The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.” — Source: MDN: Promise
  • Pending: Initial state: neither fulfilled nor rejected. — Source: MDN: Promise — States
  • Fulfilled: Settled successfully while carrying a resulting value. — Source: MDN: Promise — States
  • Rejected: Settled unsuccessfully while carrying a rejection reason. — Source: MDN: Promise — States
  • Settled: Umbrella term for a promise that is fulfilled or rejected — no longer pending. — Source: MDN: Promise — States
  • Resolved: Locked in to match another promise’s eventual state; a resolved promise can still be pending or rejected. — Source: MDN: Promise — resolved vs fulfilled
  • Executor: Function passed to new Promise; runs synchronously receiving resolve/reject. — Source: ECMA-262: Promise executor
  • Fulfillment/rejection handler: Callbacks supplied to then/catch receiving value or reason. — Source: MDN: Using promises
  • Chain: Sequence formed because every then/catch returns a fresh promise. — Source: MDN: Using promises
  • Floating Promise: A promise neither returned nor handled — a common source of unhandled rejections. — Source: WHATWG HTML: Unhandled promise rejections
  • Settled (official): "A promise is settled if it is either fulfilled or rejected, but not pending." — Source: MDN: Promise — States
  • Floating Promise (official): "A floating promise is a promise that is neither awaited, returned, nor handled — a source of unhandled rejections." — Source: WHATWG: Unhandled promise rejections

Mental model: a claim ticket, not a worker

A Promise is a claim ticket for a result. It records whether the result is pending, fulfilled, or rejected and lets code register what should happen afterward. It does not perform work, create a thread, or make synchronous computation asynchronous. The operation represented by the ticket may be a timer, fetch, user decision, or even an immediately available value.

State moves one way:

text
pending -> fulfilled(value)
        -> rejected(reason)

Settling attempts after the first have no effect. Handlers registered with then, catch, or finally never execute inline with the current synchronous run; Promise reactions are queued as microtasks.

Every call to then, catch, or finally returns a new Promise. That fact powers chaining. A handler's outcome controls the new Promise:

  • return a normal value: fulfill the next Promise with it;
  • return a Promise: the next Promise adopts its eventual state;
  • throw: reject the next Promise;
  • return nothing: fulfill the next Promise with undefined.

Self-check: draw three boxes for three Promises in a chain, with each handler between boxes. Identify the original Promise and the Promise returned by each then. Notice then does not edit one Promise repeatedly — it creates a new one. Then predict what happens when the middle handler returns 5, returns a pending Promise, or throws an error.

Beginner self-study example: prepare an order

setTimeout is callback-based, so it is reasonable to wrap this tiny simulated operation. Modern Promise-returning APIs such as fetch should not be wrapped in new Promise.

js
function prepareOrder(item, shouldSucceed = true) {
  return new Promise((resolve, reject) => {
    console.log(`Preparing ${item}`);

    setTimeout(() => {
      if (shouldSucceed) {
        resolve({ item, status: "ready" });
      } else {
        reject(new Error(`Could not prepare ${item}`));
      }
    }, 300);
  });
}

prepareOrder("noodles")
  .then((order) => {
    console.log(`${order.item}: ${order.status}`);
    return order.item.toUpperCase();
  })
  .then((label) => {
    console.log(`Label: ${label}`);
  })
  .catch((error) => {
    console.error(error.message);
  })
  .finally(() => {
    console.log("Order attempt finished");
  });

console.log("Promise returned; counter remains usable");

Step-by-step explanation

  1. new Promise constructs a pending Promise. Its executor runs immediately, prints Preparing noodles, and starts a timer.
  2. prepareOrder returns the pending Promise. Handlers are attached, and synchronous code continues.
  3. The final synchronous log appears before the timer callback.
  4. The timer calls resolve with an object. The Promise becomes fulfilled.
  5. The first then handler runs as a microtask. It returns a string, so the Promise returned by that then fulfills with the string.
  6. The next then receives that string.
  7. No error occurred, so catch is skipped while preserving fulfillment.
  8. finally runs after settlement. It is for cleanup, not transformation: it receives no result argument and normally passes the prior outcome through.

Expected output

text
Preparing noodles
Promise returned; counter remains usable
noodles: ready
Label: NOODLES
Order attempt finished

Change the call to prepareOrder("noodles", false). Expected important lines are Could not prepare noodles and Order attempt finished; the two fulfillment handlers do not run.

Chaining and error flow

Prefer a flat pipeline:

js
getUser()
  .then((user) => getOrders(user.id))
  .then((orders) => orders.length)
  .then((count) => console.log(`${count} orders`))
  .catch((error) => console.error("Pipeline failed:", error));

The return in the first handler is essential. With braces, this is broken:

js
getUser().then((user) => {
  getOrders(user.id); // Floating: the outer chain cannot wait for it.
});

Thrown errors automatically become rejections:

js
Promise.resolve({ name: "" })
  .then((user) => {
    if (!user.name) throw new Error("Name is required");
    return user.name;
  })
  .catch((error) => "Anonymous")
  .then((name) => console.log(name));

Expected output is Anonymous. A catch that returns normally recovers the chain. To log but preserve failure, throw the error again.

Intermediate example: a mock-first data pipeline

This stable example validates and transforms mock data.

js
const mockProducts = [
  { id: 1, name: "Notebook", price: 6 },
  { id: 2, name: "Pen", price: 2 },
];

function loadProducts(available = true) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (!available) {
        reject(new Error("Product service unavailable"));
        return;
      }
      resolve(mockProducts);
    }, 100);
  });
}

loadProducts()
  .then((products) => {
    if (!Array.isArray(products)) {
      throw new TypeError("Expected a product array");
    }
    return products.filter((product) => product.price >= 5);
  })
  .then((products) => products.map((product) => product.name))
  .then((names) => console.log(names.join(", ")))
  .catch((error) => console.error(error.message));

Expected output:

text
Notebook

Each transformation's returned value becomes the next input. One final catch handles a timer failure, validation error, or transformation error.

Brief callback comparison

Older callback-first APIs pass success and failure functions into an operation. Nested dependent operations can become difficult to compose and can have inconsistent error handling. Promise-returning APIs invert that arrangement: the operation returns a stable object, and callers attach handlers. Wrap a callback API once at its lowest boundary, then use Promises above it. Do not build new browser networking around XMLHttpRequest; Fetch is Promise-based.

Optional advanced stable example: all or all outcomes

js
const jobs = [
  Promise.resolve("inventory"),
  Promise.reject(new Error("pricing failed")),
  Promise.resolve("reviews"),
];

Promise.allSettled(jobs).then((results) => {
  for (const result of results) {
    if (result.status === "fulfilled") {
      console.log(`OK: ${result.value}`);
    } else {
      console.log(`ERROR: ${result.reason.message}`);
    }
  }
});

Expected output, in input order:

text
OK: inventory
ERROR: pricing failed
OK: reviews

Promise.all instead fulfills with all values or rejects when any input rejects. Its rejection does not cancel the other underlying operations. allSettled is useful when every outcome matters.

Modern Promise Construction: Promise.withResolvers()

When code genuinely needs the resolver functions outside the Promise constructor, modern JavaScript provides Promise.withResolvers().

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

setTimeout(() => {
  resolve("ready");
}, 100);

console.log(await promise);

This is roughly a clearer form of:

js
let resolve;
let reject;

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

Do not reach for external resolvers for normal async functions. Most application code should return Promises from the operation that actually owns the asynchronous work. withResolvers() is most useful when adapting event-style or externally completed APIs.

Common mistakes and debugging

  • Forgetting return inside then: inspect the next value; unexpected undefined often identifies this.
  • Nesting unnecessarily: return the inner Promise and continue a flat chain.
  • Using new Promise around a Promise: return the existing Promise. Extra wrapping often loses errors.
  • Expecting try/catch around an unreturned chain to catch later rejection: attach catch, return the chain, or use await in an async function.
  • Swallowing errors: a catch that only logs converts failure to fulfillment with undefined. Re-throw when callers must know.
  • Using finally to obtain a value: use then; finally is for cleanup such as hiding a spinner.
  • Saying resolved when fulfilled is meant: everyday shorthand exists, but use precise state words while learning.

In browser DevTools, enable "pause on caught exceptions" when needed, inspect async stack traces, and watch for unhandledrejection. Handle errors near a meaningful boundary rather than adding empty catches everywhere.

Security and performance

Do not expose raw server errors, tokens, or personal data in user-facing messages or production logs. Validate fulfilled data; a successful Promise says the operation completed, not that its value is trustworthy. Avoid unbounded Promise creation and unlimited concurrent network work. Promises retain handlers and captured data while pending, so operations that never settle can retain memory. Promises have no universal cancellation protocol; cancel the underlying operation when its API supports AbortSignal.

Exercises

Level 1: states

Name the final state and value/reason:

js
const result = Promise.resolve(4).then((number) => number * 3);

result starts pending and fulfills with 12 after the handler runs.

Level 2: convert one callback boundary

Create wait(ms) returning a Promise fulfilled after at least ms milliseconds, then print Done.

js
function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

wait(200).then(() => console.log("Done"));

Level 3: repair the chain

Fix the floating Promise:

js
loadProducts()
  .then((products) => {
    saveProducts(products);
  })
  .then(() => console.log("Saved"))
  .catch(console.error);
js
loadProducts()
  .then((products) => saveProducts(products))
  .then(() => console.log("Saved"))
  .catch(console.error);

Returning saveProducts makes "Saved" wait and routes its rejection to catch.

Recap

A Promise is an eventual result, not a thread. It moves from pending to fulfilled or rejected. Promise methods return new Promises, so returned values, returned Promises, and thrown errors form a pipeline. Keep chains flat, return every dependent Promise, handle rejection intentionally, and reserve finally for cleanup.

Official references

Promise-backed iteration

Promises describe one eventual result; an async iterator describes a sequence of results. This distinction prevents the common mistake of resolving one Promise repeatedly. A Promise settles once, whereas an async generator can yield many chunks and then finish.

js
async function* chunks(source, size) {
  for (let index = 0; index < source.length; index += size) {
    yield source.slice(index, index + size);
  }
}

(async () => {
  const output = [];
  for await (const chunk of chunks(["a", "b", "c", "d", "e"], 2)) {
    output.push(chunk.join(""));
  }
  console.assert(output.join("|") === "ab|cd|e");
})();

Do not confuse this with parallel processing: the consumer applies backpressure by requesting the next chunk only after it finishes the current one. For independent chunks, collect bounded work into a pool rather than calling Promise.all over an unbounded source.

Edge cases

  • A rejected next() Promise enters the consumer's catch; it is not silently converted to completion.
  • Breaking a for await...of loop gives the iterator an opportunity to run return(), so use try/finally for cleanup.
  • A producer that never yields or completes can retain the consumer and captured data indefinitely.
  • Promise.resolve() assimilates thenables; it does not mean the value is already fulfilled.

Interview questions

  1. Why can a Promise not represent a stream? It has exactly one settlement and result; a stream has many values, completion, and failure.
  2. What is backpressure at an async-iterator boundary? The producer waits for the consumer to request or finish the next item instead of flooding memory.
  3. How should a consumer stop a generator-backed resource? Break the loop and ensure the producer's finally closes the resource.

Promise combinators and cancellation

Combinators coordinate already-started or immediately-created operations; they do not create threads and they do not cancel inputs when the returned Promise settles.

js
const wait = (ms, value, fail = false) => new Promise((resolve, reject) => {
  setTimeout(() => fail ? reject(new Error(value)) : resolve(value), ms);
});

const jobs = [wait(30, "slow"), wait(10, "fast")];
Promise.all(jobs).then(console.log); // ["slow", "fast"]: input order

Promise.allSettled([wait(5, "ok"), wait(1, "bad", true)])
  .then((results) => console.log(results.map((result) => result.status)));

Promise.any([wait(20, "fallback", true), wait(10, "winner")])
  .then(console.log); // winner; rejects with AggregateError only if all reject

Promise.race([wait(10, "value"), wait(20, "too late", true)])
  .then(console.log); // first settlement, whether fulfillment or rejection

all is for an all-or-nothing result and rejects at the first rejection. allSettled is for a report of every outcome. any ignores rejections until it has no possible fulfillment. race settles on the first result of either kind. All four preserve the relevant input order in their result arrays where applicable, but completion order is different.

Cancellation is an explicit protocol

Rejecting the result of Promise.race does not stop its loser. Pass a signal to the underlying operation:

js
function waitWithAbort(ms, signal) {
  return new Promise((resolve, reject) => {
    if (signal.aborted) {
      reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
      return;
    }
    const timer = setTimeout(resolve, ms);
    signal.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
    }, { once: true });
  });
}

const controller = new AbortController();
const request = waitWithAbort(1000, controller.signal);
controller.abort();
request.catch((error) => console.assert(error.name === "AbortError"));

An API that does not observe signal cannot be magically canceled. A caller may still ignore a stale result, but that is stale-result protection, not resource cancellation.

Interview questions and tests

  1. What does Promise.all reject with if two inputs fail? The first rejection observed by the combinator; use allSettled when every reason matters.
  2. Does Promise.any([]) fulfill? No; it rejects immediately with AggregateError because no input can fulfill. Promise.all([]) fulfills with [], while race([]) remains pending.
  3. Why is Promise.race([fetchPromise, timeoutPromise]) not a complete timeout? The losing fetch continues unless it receives an AbortSignal.
  4. What should a UI do when a canceled request rejects? Usually treat expected abort as silent control flow, while reporting real failures.
js
Promise.all([]).then((value) => console.assert(value.length === 0));
Promise.any([]).catch((error) => console.assert(error instanceof AggregateError));
const pending = Promise.race([]);
setTimeout(() => console.log("empty race is still pending", pending), 0);

These are useful interview edge cases because they test semantics rather than timing guesses.