Appearance
Promises and Asynchronous Control Flow
An asynchronous operation begins now and completes later. A promise is an object representing that eventual completion. It is pending, then becomes fulfilled with a value or rejected with a reason. Once settled, its outcome never changes.
js
const textPromise = readFile("transactions.json", "utf8");The call has already asked the environment to begin reading. textPromise is not the text and await does not start it; the promise represents work already initiated by readFile.
Chaining transforms future values
then registers what to do after fulfillment and returns a new promise:
js
const reportPromise = readFile("transactions.json", "utf8")
.then(text => JSON.parse(text))
.then(transactions => buildReport(transactions));If a callback returns an ordinary value, the next promise fulfills with it. If it returns a promise, the chain adopts that promise's eventual outcome. If it throws, the next promise rejects.
js
const displayed = reportPromise
.then(report => writeReport(report))
.catch(error => {
console.error("Report failed", error);
});catch(handler) is effectively then(undefined, handler). It catches rejections created earlier in that chain, including exceptions thrown by fulfillment handlers. Like synchronous catch, it can recover by returning a value or continue failure by throwing.
finally runs after settlement and passes through the original result unless its callback fails. It is useful for hiding a spinner or releasing a resource.
async and await are promise syntax
An async function always returns a promise:
js
async function loadReport(path) {
const text = await readFile(path, "utf8");
const transactions = JSON.parse(text);
return buildReport(transactions);
}Returning report fulfills the returned promise. Throwing rejects it. await pauses only this async function until its operand settles; it does not block the thread. A surrounding caller must still await or otherwise handle the returned promise:
js
try {
const report = await loadReport("transactions.json");
console.log(report);
} catch (error) {
console.error(error);
}Top-level await is available inside ESM, but making reusable modules perform top-level network or filesystem work can delay every importer. Entry points are the most natural place for it.
Do not wrap an existing promise merely because a function is asynchronous:
js
// Unnecessary and easy to get wrong
function loadTextWrapped(path) {
return new Promise((resolve, reject) => {
readFile(path, "utf8").then(resolve, reject);
});
}
// Preserve the existing promise
function loadText(path) {
return readFile(path, "utf8");
}Construct new Promise mainly when adapting a callback-based API whose completion you control.
Sequential syntax can hide sequential work
This code waits for one independent request before beginning the next:
js
const customers = await fetchCustomers();
const products = await fetchProducts();If neither depends on the other, start both before waiting:
js
const customersPromise = fetchCustomers();
const productsPromise = fetchProducts();
const [customers, products] = await Promise.all([
customersPromise,
productsPromise,
]);Or more compactly:
js
const [customers, products] = await Promise.all([
fetchCustomers(),
fetchProducts(),
]);Promise.all preserves input order and rejects as soon as one input rejects. It does not cancel the other work. If partial outcomes matter, Promise.allSettled waits for every input and returns records describing fulfillment or rejection:
js
const outcomes = await Promise.allSettled(urls.map(loadDocument));
for (const outcome of outcomes) {
if (outcome.status === "fulfilled") {
console.log(outcome.value);
} else {
console.error(outcome.reason);
}
}Promise.race adopts the first settled outcome. Promise.any fulfills with the first successful value and rejects only if every input rejects. None of these combinators stops losing operations; cancellation needs cooperation, discussed next.
Unlimited Promise.all(items.map(...)) can start thousands of requests at once. Concurrency and parallelism are design decisions, not automatic improvements.
Promise ownership must be visible
A common bug starts asynchronous work but fails to return it:
js
function saveAll(records) {
records.map(record => {
saveRecord(record); // promises are discarded
});
}The function returns undefined immediately and failures may become unhandled rejections. Return the combined promise:
js
function saveAll(records) {
return Promise.all(records.map(record => saveRecord(record)));
}Another trap is forEach with an async callback:
js
records.forEach(async record => {
await saveRecord(record);
});forEach ignores callback return values. Use Promise.all for concurrent work or a loop for intentional sequencing:
js
for (const record of records) {
await saveRecord(record);
}Sometimes work is intentionally detached, such as best-effort telemetry. Make that policy explicit and handle rejection:
js
void sendTelemetry(event).catch(error => {
console.warn("Telemetry failed", error);
});The void does not alter execution; it tells a reader and many linters that the promise is deliberately not awaited.
Errors follow the asynchronous chain
This try does not catch a later rejection because the promise is returned without being awaited inside the protected block:
js
async function loadOptional(path) {
try {
return readFile(path, "utf8");
} catch {
return null;
}
}Await it when this function owns the recovery:
js
async function loadOptional(path) {
try {
return await readFile(path, "utf8");
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}The apparently redundant await changes which function's catch observes rejection. Elsewhere, return promise and return await promise usually settle identically, so use the simpler form unless local error handling or cleanup depends on awaiting.
Promises make future completion composable. To predict exactly when their callbacks run—and to design cancellation and bounded concurrency—we need the event loop.
Combinators do not cancel losing work
Promise combinators observe outcomes; they do not stop the operations represented by other promises. A timeout made with Promise.race can stop waiting while its request continues. Pass an abort signal when the work itself should stop.
Empty inputs differ: Promise.all([]) and allSettled([]) fulfill with empty arrays, Promise.any([]) rejects with an AggregateError, and Promise.race([]) remains pending. A dynamically constructed group should decide whether zero operations is valid.
Every started promise also needs an owner. If telemetry is intentionally detached, make both choices visible:
js
await save(record);
void sendTelemetry("record-saved").catch(reportTelemetryFailure);The void communicates that telemetry is not part of the caller's completion; the rejection handler still gives failure an owner.
Optional prompts
Predict: Compare await loadA(); await loadB(); with await Promise.all([loadA(), loadB()]). When does each operation start, and what happens if loadA rejects?
Answer: In the sequential form,
loadBstarts only afterloadAfulfills, so it never starts ifloadArejects. In thePromise.allform, both calls run while constructing the input array, before the combined await. The combined promise rejects when an input rejects, but that does not automatically cancel the other operation.
Debug: Why does the final handler receive undefined here: fetchReport().then(report => { save(report); }).then(showSaved)?
Answer: A block-bodied arrow returns
undefinedunless it has an explicitreturn. Usereturn save(report)so the chain waits for and adopts that promise, or use the concise form.then(report => save(report)). The next handler then receivessave's fulfillment value.
Explain: Why does returning a promise from a then handler not produce a permanently nested Promise<Promise<T>>?
Answer: Promise resolution adopts the returned promise's eventual state. The next link waits for it and receives its fulfillment value or rejection. This flattening is what lets a chain represent sequential asynchronous steps without manual unwrapping.