What should a slow function return?
A code-review service builds the page for a pull request, and the first thing it needs is the pull request's row, which the database takes 40 ms to return. What should getPullRequest(42) hand back during those 40 ms? Not the row, which does not exist yet. It could make its caller wait, but then the caller's thread does nothing for 40 ms. On a server with a thread per request that is a thread held idle; in a runtime with a single thread, such as JavaScript in a browser or Node.js, it holds every other request too, which is the subject of Event Loop. How that design compares with threads and locks is in Concurrency patterns.
A function you hand over
The first answer that does not wait is a callback: the caller passes in a function, and the slow function calls it when the result is ready. getPullRequest(42, (err, pr) => …) returns immediately. Then the page needs three steps, each depending on the one before: load the pull request, fetch the CI run for its head commit, notify the reviewers. Each step moves inside the previous one's callback, the code drifts one level right per step, and every level must check err itself; a level that forgets drops the error, and the reviewers are never told. Nothing in the style stops a buggy library from calling your callback twice, either, or never.
An object that stands for the answer
A future or promise turns this inside out. The function returns an object at once, and the object stands for the result to come: the caller can store it, pass it on and attach code to it before the result exists. It is in one of three states:
- Pending: there is no value or error yet.
- Fulfilled: it finished, and the promise now holds its value.
- Rejected: it failed, and the promise now holds the error.
A promise that is fulfilled or rejected is settled, and it settles once. A later attempt to settle it again does not change it; depending on the runtime, the attempt is ignored or raises an error. So the callback called twice has no equivalent. Code attached after settling still runs, with the stored value, so a caller that attaches late misses nothing. What a promise does not prevent is never: an operation that never answers leaves its promise pending for good, and bounding that wait is the job of Timeout.
The two names usually mean the same thing; where a library has both, the future is the side that reads the result and the promise the side that sets it. In JavaScript the setting side is the pair of functions, resolve and reject, that new Promise((resolve, reject) => …) hands to the code doing the work; wrapping a callback API means calling one of them from its callback. Across a network the same idea becomes a status URL returned with 202 Accepted, which is Asynchronous Request-Reply.
then returns a new promise
The attached code is a continuation: a function that runs when the promise settles. In JavaScript it is attached with then and never runs inside that call, and the detail that removes the nesting is that then returns a new promise. If the continuation returns a value, the new promise is fulfilled with that value; if it returns another promise, the new one waits for that one and settles the same way. The three steps become a flat chain:
getPullRequest(42)returns a promise for the pull request..then(pr => getCiRun(pr.headCommit))returns a promise for the CI run, settled when the CI service answers..then(run => notifyReviewers(42, run))returns a promise for the notification..catch(err => log(42, err))handles a failure from any step above it.
Errors travel along the same chain. If the lookup rejects, or a continuation throws, the promise that then returned is rejected, and each later then that has no error handler passes the rejection on unchanged, skipping its own code, until it reaches a catch. One handler at the end covers every step. A catch that returns a plain value recovers: its promise is fulfilled and the chain after it carries on, so place one mid-chain only on purpose, because a catch that logs and returns nothing hands the next step undefined.
Diagram components: Client, Service, Future / Promise, Handler, Future / Promise, Handler.
The review page holds the pull-request promise from the first instant. If the lookup fails, the CI step never runs: its promise is rejected with the same error, which reaches the catch.
await, and the 360 ms it made easy to write
async and await are syntax over the same promises. Inside a function marked async, const pr = await getPullRequest(42) suspends that function until the promise settles, without blocking the thread: other work runs meanwhile, and the function resumes with the value, or with the error thrown at the await, where an ordinary try/catch handles it. The async function itself returns a promise, fulfilled with what it returns and rejected with what it throws. Because other code runs during the suspension, a check made before an await may no longer hold after it; Event Loop shows how that goes wrong.
The convenience makes one mistake easy. The page also shows the comments and the suggested reviewers, and all three lookups need only the pull request's number. The team writes:
const pr = await getPullRequest(42), which takes 40 ms.const comments = await getComments(42), which takes 120 ms.const reviewers = await suggestReviewers(42), which takes 200 ms.
Each await finishes before the next call is made, so the page takes 40 + 120 + 200 = 360 ms, though nothing required that order. Starting all three and awaiting them together takes as long as the slowest, 200 ms: const [pr, comments, reviewers] = await Promise.all([getPullRequest(42), getComments(42), suggestReviewers(42)]). This works because each function starts its lookup when it is called; awaiting a promise starts nothing, it only waits.
Diagram components: Client, Future / Promise, Future / Promise, Future / Promise, Handler.
All three requests are in flight at once, and Promise.all fulfils when the slowest one does.
When one of the three fails
Promise.all fulfils with the values, in the order given, once all of them have fulfilled, and rejects as soon as any one rejects, with that error. The others are not stopped: if the reviewer service fails after 80 ms, the page gets the rejection at 80 ms while the comments request keeps running, and its result is thrown away. When the page can do without one part, Promise.allSettled waits for every promise and reports each outcome instead.
Nothing stopped the comments request because a basic promise represents a result, not the work producing it: a JavaScript Promise has no cancel method. Stopping work takes a separate signal passed into the operation, such as an AbortSignal from an AbortController, which fetch accepts, or a CancellationToken in .NET, and the operation has to honour it.
Three ways a result goes missing
The first is a missing await. The variable then holds the promise instead of the value, and nothing complains: "Title: " + pr prints Title: [object Promise], and if (user.isAdmin()), with an async isAdmin, tests a promise object, which is always truthy, so every user passes. The operation still runs, unobserved.
If it fails, no caller hears it, which is the second mistake: a rejection that nothing awaits and no catch handles. Runtimes report it as an unhandled rejection: browsers log it to the console, and Node.js, by default since version 15, treats it as an uncaught exception, which ends the process unless something handles that. Every promise needs an owner that awaits it or attaches a handler.
The third is blocking on a result from the place that must produce it. Java's future.get() and C#'s task.Result hold the calling thread until the result exists; in C#, when the task's await has to resume on that same single thread, as on a desktop UI thread, each waits for the other for good. The pool version is worked through in Thread Pool. The cure is to await instead of blocking, all the way up the call chain.
The review page on a Grid
The Future / Promise challenge grades this mechanism requirement by requirement: a caller that gets a Future at once instead of the result, the operation that later fulfils or rejects it, continuations, errors reaching the rejection path, and futures chained or combined. The chain in the first diagram uses six of the challenge's eight nodes, and the two spare ones are enough to start a second lookup and join both in a Handler, the way the three lookups were joined. Keep the Observer node for something else: an observer is notified of every change of its subject, while a continuation runs once, because a promise settles once.