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 loadText(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.