JavaScript140 min total · 15 parts
JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop
Part 10 of 15 · ~7 min
Promises: Representing a Future Value
A Promise is an object that stands in for a value you do not have yet. It is in exactly one of three states:
- pending — the answer is not in yet
- fulfilled — the answer arrived, and the Promise carries it
- rejected — the answer is never coming, and the Promise carries the reason
Once it moves from pending to either of the other two it is settled, and a settled Promise never changes again. That permanence is the whole reason Promises are safe to pass around: whoever you hand it to will see one outcome, whenever they look, however many times they look.
Most of the time you receive Promises rather than construct them — fetch hands you one, and so does every modern browser API. But it is worth building exactly one by hand, because the constructor shows you the machinery, and because our search box has a genuine reason to need it.
Cast your mind back to chapter one, where the article index was loaded like this:
const ARTICLES = loadArticleIndex();
That was fine when the index was a small array in a bundled file. It stops being fine the moment the index is large enough to fetch separately — and loadArticleIndex is an older utility with a callback-style signature, which is not something we can await. So we wrap it:
function loadIndex() {
return new Promise((resolve, reject) => {
loadArticleIndex(
(articles) => resolve(articles), // the value is ready — fulfil with it
(err) => reject(err) // it is never coming — reject with why
);
});
}
The function you pass to new Promise is called the executor, and it runs immediately and synchronously. It is handed two functions. Call resolve(value) and the Promise fulfils with that value; call reject(reason) and it rejects with that reason. Wrapping a callback API this way is essentially the only reason to write new Promise yourself.
The settle-once rule is not a guideline — the engine enforces it, and you can lean on that:
function loadIndexWithTimeout() {
return new Promise((resolve, reject) => {
loadArticleIndex(resolve, reject);
setTimeout(() => reject(new Error("index load timed out")), 5000); // whichever lands first wins
});
}
If the index arrives at 4.8 seconds, resolve settles the Promise and the reject at 5 seconds is silently ignored — not an error, not a warning, simply a no-op on an already-settled Promise. If the index arrives at 6 seconds, it is the resolve that gets ignored. Either way the consumer sees exactly one outcome.
Now the everyday case, the one our search box actually runs on:
function fetchResults(query) {
return fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then((res) => {
if (!res.ok) throw new Error(`Search failed: ${res.status}`); // 404/500 are NOT rejections
return res.json(); // itself a Promise — the chain waits
});
}
That res.ok check is not defensive padding, and leaving it out is one of the most common real bugs in fetch code. fetch only rejects when the request itself failed — no network, DNS failure, connection dropped. A server that answers with a 500 answered successfully as far as fetch is concerned, so the Promise fulfils, res.json() parses whatever error page came back, and your dropdown renders an empty result list instead of telling anyone the search is down.
Chaining and error propagation
Here is the search box's render path as a chain:
spinnerEl.hidden = false;
fetchResults(query)
.then((results) => rankByRecency(results)) // transform
.then((ranked) => renderRows(ranked)) // render
.catch((err) => renderError(err)) // one handler for every failure above
.finally(() => { spinnerEl.hidden = true; }); // runs on success AND failure
Every .then() returns a new Promise. That is the entire basis of chaining, and four rules fall out of it:
- Return a plain value from a
.thencallback and the new Promise fulfils with that value, which becomes the next callback's argument. - Return a Promise from a
.thencallback and the chain adopts it — the next link waits for that inner Promise to settle before it runs. This is what letsfetchResultsreturnres.json()and have callers receive parsed data rather than a Promise wrapped in a Promise. - Throw anywhere in the chain, or return a rejected Promise, and every subsequent
.thenis skipped until a.catchis found..catch(fn)is not special syntax; it is.then(undefined, fn)with a better name. .finally(fn)runs on both paths and passes the settlement straight through untouched. It cannot change the value and it cannot swallow a rejection — which is precisely why it is the right place for the spinner, and the wrong place for anything that decides what the user sees.
The bug this structure invites is the missing return:
fetchResults(query)
.then((results) => { rankByRecency(results); }) // braces, no return — undefined flows onward
.then((ranked) => renderRows(ranked)); // ranked is undefined
Adding braces to an arrow function turns its expression body into a statement body, and a statement body returns undefined unless you say otherwise. The chain keeps working perfectly — it just carries undefined from that point on, and the failure surfaces several links later in a function that did nothing wrong.
One more placement rule worth internalising: a .catch only catches what is above it. This chain has a hole in it —
fetchResults(query)
.catch((err) => renderError(err)) // catches the fetch
.then((ranked) => renderRows(ranked)); // if THIS throws, nothing catches it
— and a throw inside renderRows becomes an unhandled rejection: no catch block runs, no error appears in your own code, and the only sign is a console warning that names a line you are not looking at. As a default, put the catch last.
Combining multiple Promises
Our dropdown does not show one thing. It shows matching articles, a set of popular searches when the query is empty, and a count of total matches. Written naively, it fetches them one after another:
async function loadDropdown(query) {
const results = await fetchResults(query); // 180ms
const popular = await fetchPopularSearches(); // 120ms — starts only now
return { results, popular }; // 300ms total, for no reason
}
Neither request needs anything from the other. They should run at the same time, and there are four combinators for saying so, each answering a different question about a group of Promises:
| Combinator | Fulfils when | Rejects when | Where the search box uses it |
|---|---|---|---|
Promise.all([...]) | Every Promise fulfils — the value is an array of results in input order, not completion order | Any one rejects, immediately, discarding the rest | Results plus the total count: the dropdown header cannot render without both |
Promise.allSettled([...]) | Every Promise settles, whatever the outcome — never rejects itself | Never; each entry reports its own status | Results plus popular searches plus recent history: a dead endpoint should cost you one panel, not the dropdown |
Promise.race([...]) | The first Promise to settle either way | Same — first to settle, win or lose | Racing the search request against a timeout |
Promise.any([...]) | The first Promise to fulfil, ignoring earlier rejections | Only if all of them reject, with an AggregateError holding every reason | Two search backends: take whichever answers first, complain only if both are down |
async function loadDropdown(query) {
const [results, total] = await Promise.all([
fetchResults(query),
fetchResultCount(query),
]);
return { results, total }; // ~180ms — the slower of the two, not the sum
}
Two precise points about Promise.all that trip people up.
First, "fails fast" describes the result, not the requests. A rejection settles the combined Promise immediately, but the other requests are already in flight and keep going to completion; nothing is cancelled. If you need them actually stopped, that is AbortController, which we get to in the next chapter.
Second, the ordering guarantee is about the input array, not about who answered first. Promise.all([a, b]) always gives you [aResult, bResult], even if b came back in 20 milliseconds and a took two seconds. That is what makes array destructuring safe here.
And the reason allSettled exists at all is visible in our dropdown. With Promise.all, one flaky "popular searches" endpoint blanks the entire panel including the results the user actually asked for:
const outcomes = await Promise.allSettled([
fetchResults(query),
fetchPopularSearches(),
fetchRecentHistory(),
]);
const [resultsOutcome] = outcomes;
if (resultsOutcome.status === "fulfilled") renderRows(resultsOutcome.value);
else renderError(resultsOutcome.reason);
// the other two panels render or stay empty on their own terms
Every entry is an object rather than a bare value — { status: "fulfilled", value } or { status: "rejected", reason } — which is the cost of the guarantee. You trade a clean destructure for never losing one panel to another panel's bad day.