080: Promises and Promise Combinators
Learning outcomes
By the end of this lesson, you should be able to:
- identify the pending, fulfilled, and rejected states;
- consume Promises with
then,catch, andfinally; - 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
Before getting into the details, test your current execution-model intuition:
- What runs first: current synchronous code or a callback passed to
.then()on an already-fulfilled Promise? - Why can a zero-delay timer still run late?
- What does "run to completion" mean?
Vocabulary
These terms are close enough to be confused, so keep their precise meanings available as you work through the examples.
- 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
The most useful starting model is that a Promise is a claim ticket for a result. It records whether that result is pending, fulfilled, or rejected, and it gives code a place to register what should happen next. The Promise itself does not perform the work, create a thread, or turn synchronous computation into asynchronous computation. The operation represented by the ticket might be a timer, a fetch, a user decision, or a value that is available immediately.
Its state moves in one direction:
pending -> fulfilled(value)
-> rejected(reason)
Only the first settling attempt matters; later attempts have no effect. Handlers registered with then, catch, or finally do not run inline during the current synchronous turn. Promise reactions are queued as microtasks.
There is one detail that explains most Promise chaining: every call to then, catch, or finally returns a new Promise. The handler's outcome determines that 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 two boxes. Mark the original Promise and the Promise returned by each then. then does not repeatedly edit one Promise; it creates another one. Then predict the result 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 wrapping this small simulation is a reasonable use of the Promise constructor. By contrast, modern Promise-returning APIs such as fetch should not be wrapped in new Promise; they already provide the abstraction you need.
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
new Promiseconstructs a pending Promise. Its executor runs immediately, printsPreparing noodles, and starts the timer.prepareOrderreturns that pending Promise. The handlers are attached, and synchronous execution continues.- The final synchronous log appears before the timer callback runs.
- The timer calls
resolvewith an object, so the Promise becomes fulfilled. - The first
thenhandler runs as a microtask. It returns a string, so the Promise returned by thatthenfulfills with the string. - The next
thenreceives that string. - Because no error occurred,
catchis skipped and the chain remains fulfilled. finallyruns after settlement. It is for cleanup rather than transformation: it receives no result argument and, in the normal case, passes the earlier outcome through.
Expected output
Preparing noodles
Promise returned; counter remains usable
noodles: ready
Label: NOODLES
Order attempt finished
Change the call to prepareOrder("noodles", false). The important output includes Could not prepare noodles and Order attempt finished; neither fulfillment handler runs.
Chaining and error flow
When several asynchronous steps depend on one another, a flat pipeline makes the dependencies and the error boundary visible:
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 what connects getOrders to the outer chain. With braces, this version is broken:
getUser().then((user) => {
getOrders(user.id); // Floating: the outer chain cannot wait for it.
});
The inner Promise is now floating. The outer chain has no way to wait for it or route its rejection to the later handlers.
Errors thrown inside a handler automatically become rejections of the next Promise:
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));
The expected output is Anonymous. A catch that returns normally recovers the chain. If you need to log the problem while preserving the failure for a caller farther up, throw the error again.
Intermediate example: a mock-first data pipeline
Before introducing a real service, this stable mock-data example lets you focus on validation, filtering, transformation, and failure flow:
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:
Notebook
Each returned transformation becomes the input to the next handler. The final catch can handle the timer failure, the validation error, or an error from a transformation.
Brief callback comparison
Older callback-first APIs pass success and failure functions into the operation itself. Once dependent operations become nested, composition gets difficult and error handling often becomes inconsistent. Promise-returning APIs use a different arrangement: the operation returns a stable object, and the caller attaches handlers to it. If you must adapt a callback API, wrap it once at its lowest boundary and use Promises above that boundary. For new browser networking, use Fetch rather than building around XMLHttpRequest; Fetch is Promise-based.
Optional advanced stable example: all or all outcomes
Sometimes the application needs every result, including failures, instead of stopping at the first rejection. Promise.allSettled models that requirement:
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:
OK: inventory
ERROR: pricing failed
OK: reviews
Promise.all fulfills with all values when every input fulfills, or rejects when any input rejects. Its rejection does not cancel the other underlying operations. Use allSettled when every outcome matters.
Modern Promise Construction: Promise.withResolvers()
Most application code should let the function that owns asynchronous work create and return its Promise. There are cases, however, where resolver functions genuinely need to be available outside the constructor. Modern JavaScript provides Promise.withResolvers() for that shape:
const {
promise,
resolve,
reject,
} = Promise.withResolvers();
setTimeout(() => {
resolve("ready");
}, 100);
console.log(await promise);
This is roughly a clearer form of:
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
Do not use external resolvers for ordinary async functions. withResolvers() is most useful when adapting an event-style API or another operation that completes from outside the code that creates the Promise.
Common mistakes and debugging
- Forgetting
returninsidethen: inspect the value received by the next handler. Unexpectedundefinedis often the clue. - Nesting unnecessarily: return the inner Promise and keep the pipeline flat.
- Using
new Promisearound a Promise: return the existing Promise instead. Extra wrapping can lose errors. - Expecting
try/catcharound an unreturned chain to catch later rejection: attachcatch, return the chain, or useawaitinside an async function. - Swallowing errors: a
catchthat only logs changes the chain into fulfillment withundefined. Re-throw when callers need to know that the operation failed. - Using
finallyto obtain a value: usethenfor values;finallyis for cleanup such as hiding a spinner. - Saying resolved when fulfilled is meant: everyday shorthand is common, but use the precise state terms while learning and debugging.
In browser DevTools, enable "pause on caught exceptions" when that helps locate the original failure. Inspect async stack traces and watch for unhandledrejection. Handle errors at a meaningful boundary instead of adding empty catches everywhere; an empty catch hides the evidence without fixing the failure.
Security and performance
Do not expose raw server errors, tokens, or personal data in user-facing messages or production logs. Validate fulfilled data: a successfully fulfilled Promise tells you that the operation completed, not that its value is trustworthy. Avoid unbounded Promise creation and unlimited concurrent network work. While a Promise is pending, it retains its handlers and captured data, so an operation that never settles can retain memory. Promises have no universal cancellation protocol; when the underlying API supports AbortSignal, cancel that operation explicitly.
Exercises
Level 1: states
Name the final state and value/reason:
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) so it returns a Promise fulfilled after at least ms milliseconds, then print Done.
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:
loadProducts()
.then((products) => {
saveProducts(products);
})
.then(() => console.log("Saved"))
.catch(console.error);
loadProducts()
.then((products) => saveProducts(products))
.then(() => console.log("Saved"))
.catch(console.error);
Returning saveProducts makes "Saved" wait for the save operation and routes a rejection from that operation to catch.
Recap
A Promise represents 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
- MDN: Promise
- MDN: Using promises
- ECMAScript specification: Promise objects
- WHATWG HTML: Unhandled promise rejections
- WHATWG DOM: AbortController
Promise-backed iteration
A Promise describes one eventual result. An async iterator describes a sequence of results. Keeping those models separate prevents a common mistake: trying to resolve one Promise repeatedly. A Promise settles once; an async generator can yield multiple chunks and then finish.
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");
})();
This example is not parallel processing. The consumer applies backpressure by requesting the next chunk only after it has finished with the current one. For independent chunks, collect bounded work into a pool instead of calling Promise.all over an unbounded source.
Edge cases
- A rejected
next()Promise enters the consumer'scatch; it is not silently converted to completion. - Breaking a
for await...ofloop gives the iterator an opportunity to runreturn(), so usetry/finallyfor 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
- Why can a Promise not represent a stream? It has exactly one settlement and result; a stream has many values, completion, and failure.
- 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.
- How should a consumer stop a generator-backed resource? Break the loop and ensure the producer's
finallycloses the resource.
Promise combinators and cancellation
Combinators coordinate operations that have already been started or immediately created. They do not create threads, and they do not cancel their inputs when the returned Promise settles.
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 reports every outcome. any ignores rejections until no input can fulfill. race settles on the first result of either kind. Where a result array is returned, the combinators preserve the relevant input order; that is different from completion order.
Cancellation is an explicit protocol
Rejecting the result of Promise.race does not stop the operation that lost the race. Cancellation has to be supported by, and passed to, the underlying operation:
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 can still ignore a stale result, but ignoring that result protects application state; it does not cancel the resource or work underneath it.
Interview questions and tests
- What does
Promise.allreject with if two inputs fail? The first rejection observed by the combinator; useallSettledwhen every reason matters. - Does
Promise.any([])fulfill? No; it rejects immediately withAggregateErrorbecause no input can fulfill.Promise.all([])fulfills with[], whilerace([])remains pending. - Why is
Promise.race([fetchPromise, timeoutPromise])not a complete timeout? The losing fetch continues unless it receives anAbortSignal. - What should a UI do when a canceled request rejects? Usually treat an expected abort as silent control flow while reporting real failures.
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 the combinators' semantics rather than guesses about timing.
