Skip to main content
CodeOath
← All posts

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 .then callback and the new Promise fulfils with that value, which becomes the next callback's argument.
  • Return a Promise from a .then callback and the chain adopts it — the next link waits for that inner Promise to settle before it runs. This is what lets fetchResults return res.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 .then is skipped until a .catch is 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:

CombinatorFulfils whenRejects whenWhere the search box uses it
Promise.all([...])Every Promise fulfils — the value is an array of results in input order, not completion orderAny one rejects, immediately, discarding the restResults plus the total count: the dropdown header cannot render without both
Promise.allSettled([...])Every Promise settles, whatever the outcome — never rejects itselfNever; each entry reports its own statusResults 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 waySame — first to settle, win or loseRacing the search request against a timeout
Promise.any([...])The first Promise to fulfil, ignoring earlier rejectionsOnly if all of them reject, with an AggregateError holding every reasonTwo 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.