Concurrency Patterns
Futures & Promises
A handle to a value that hasn't arrived yet — so your code never has to wait for it
A future (or promise) is a placeholder object for a value that isn't ready yet, letting you register callbacks and compose asynchronous work without blocking the calling thread.
- Statespending → fulfilled / rejected
- Settlesexactly once (immutable)
- Callback timingalways async (microtask)
- Composition.then / all / race / await
- First shippedMultiLisp, 1985
Interactive visualization
Press play, or step through manually. The visualization is yours to drive — try it before reading on.
Watch the 60-second explainer
A condensed visual walkthrough — narrated, captioned, under a minute.
The intuition: an IOU for a value
Imagine you order a coffee and the barista hands you a numbered receipt instead of the drink. You don't stand frozen at the counter — you take the receipt, find a seat, answer an email. The receipt is the coffee, eventually. When your number is called, the value materializes. That receipt is a future, and the barista's commitment to fill it is a promise.
Concretely, a promise is a small object with two parts: a state and an eventual value. It starts pending — no value yet. Some asynchronous work runs elsewhere: a network request, a disk read, a computation on a thread pool. When that work finishes, it settles the promise, either fulfilled with a value or rejected with an error. Crucially, the function that handed you the promise returned immediately, before any of this happened. Your thread never blocked.
The magic is in how you read the result. Instead of polling while (!ready) {}, you register a continuation: "when the value arrives, run this." That callback is the heart of composition — you describe what should happen next without waiting for now.
The state machine and the settle-once rule
Every promise is a tiny three-state machine, and the transitions are one-way:
resolve(v)
┌───────┐ ─────────────▶ ┌────────────┐
│pending│ │ fulfilled │ (value = v)
└───────┘ ─────────────▶ └────────────┘
reject(e)
│
▼
┌────────────┐
│ rejected │ (reason = e)
└────────────┘
The single most important property: a promise settles exactly once and is then immutable. The first resolve or reject wins; every subsequent call is ignored (JavaScript) or throws std::future_error (C++). This immutability is what makes promises composable and thread-safe to read: once you've seen a fulfilled promise, it can never become rejected under you, and no data race can change the value mid-read.
Immutability also explains a question that confuses newcomers — what if I subscribe after it already resolved? Your callback still fires. A settled promise simply replays its stored value to every new subscriber. The order of "subscribe" versus "resolve" never matters, which removes an entire category of timing bugs.
One subtle but mandated rule: callbacks never run synchronously inside .then, even if the promise is already settled. They are deferred to the next turn of the event loop — the microtask queue in JavaScript. This guarantees a function is never "sometimes sync, sometimes async" (the bug Isaac Schlueter named "releasing Zalgo"), so the code immediately after .then always runs before the callback.
When to reach for a promise
- Any single async result — an HTTP call, a file read, a database query, a timer. One operation, one eventual value.
- Composing dependent steps — "fetch the user, then their orders, then render." Chaining replaces the nested-callback "pyramid of doom."
- Fan-out / fan-in — fire ten requests and wait for all (
Promise.all) or for the fastest (Promise.race). - Decoupling producer from consumer — the thread computing a value and the thread reading it never need to meet; the promise is the rendezvous.
Reach for something else when the source produces many values over time, not one — that's a stream or an observable (RxJS, reactive streams), not a promise. A promise resolves once; a stream emits repeatedly. And if you need to cancel in-flight work, note that a bare promise has no cancellation — you need an AbortController or a cancellation token alongside it.
Futures & promises across languages
| JavaScript Promise | C++ std::future | Java CompletableFuture | Python asyncio.Future | Rust Future | Scala Future | |
|---|---|---|---|---|---|---|
| Read / write split | merged (one object) | future + promise | merged | merged | poll-based (lazy) | future + Promise |
| Blocking get | none (must await) | .get() blocks | .get() blocks | .result() blocks | none — driven by executor | Await.result blocks |
| Non-blocking callback | .then | none (need std::async) | .thenApply | add_done_callback | .await in async fn | .map / .flatMap |
| Eager or lazy | eager (runs at creation) | eager | eager | eager | lazy (does nothing until polled) | eager |
| Chaining returns | new Promise | n/a | new stage | n/a | new Future | new Future |
| Error channel | reject / .catch | stored exception | .exceptionally | set_exception | Result<T,E> | .recover |
The headline distinction is eager vs lazy. In JavaScript, Java, and Scala a promise starts its work the moment it's created — by the time you have the object, the request is already in flight. Rust is the outlier: a Future is inert until an executor polls it, which is why a Rust future you forget to .await does nothing at all, whereas a JS promise you forget to await still runs (and may throw an unhandled rejection).
What the numbers actually say
- A microtask is cheap but not free. Each
.thenschedules a microtask; resolving a chain of n promises means n microtask drains. On V8 a microtask dispatch is on the order of tens of nanoseconds, so a 5-stage chain costs roughly 100–300 ns of scheduling overhead — negligible next to the millisecond-scale I/O it's wrapping. - Blocking wastes a whole thread. A thread parked in C++
future.get()holds ~1 MB of stack and an OS scheduler slot doing nothing. Serving 10,000 concurrent slow requests with blocking threads needs ~10 GB of stack; the same load on a single-threaded promise/event-loop model needs one thread and a few KB per pending promise. That memory gap is the entire reason Node.js and nginx exist. - Promises don't add parallelism — they add concurrency.
Promise.all([a, b, c])on three 100 ms network calls finishes in ~100 ms, not 300 ms, because the calls overlap in flight. But three CPU-bound 100 ms computations still take 300 ms on one thread — promises interleave waiting, not computing. - Unhandled rejections leak. A rejected promise with no
.catchkeeps its error alive and triggers anunhandledRejectionevent; in Node ≥15 that terminates the process by default.
JavaScript implementation
JavaScript's Promise is native, but building a minimal one yourself is the clearest way to see the state machine. Here is a compact .then-able promise that respects the settle-once and always-async rules:
const PENDING = 0, FULFILLED = 1, REJECTED = 2;
class Tiny {
constructor(executor) {
this.state = PENDING;
this.value = undefined;
this.cbs = []; // queued { onF, onR, resolve, reject }
const settle = (state, value) => {
if (this.state !== PENDING) return; // settle-once: ignore extra calls
this.state = state;
this.value = value;
// always async: flush on the microtask queue, never synchronously
queueMicrotask(() => this.cbs.forEach(c => this._run(c)));
};
try { executor(v => settle(FULFILLED, v), e => settle(REJECTED, e)); }
catch (e) { settle(REJECTED, e); }
}
_run({ onF, onR, resolve, reject }) {
const handler = this.state === FULFILLED ? onF : onR;
if (typeof handler !== 'function') { // pass through
return this.state === FULFILLED ? resolve(this.value) : reject(this.value);
}
try { resolve(handler(this.value)); } // chain: feed the next promise
catch (e) { reject(e); }
}
then(onF, onR) {
return new Tiny((resolve, reject) => {
const cb = { onF, onR, resolve, reject };
if (this.state === PENDING) this.cbs.push(cb);
else queueMicrotask(() => this._run(cb)); // already settled? still async
});
}
catch(onR) { return this.then(undefined, onR); }
}
// Usage — note the call returns instantly, callback fires later:
new Tiny((res) => setTimeout(() => res(20), 50))
.then(v => v * 2) // 40
.then(v => v + 1) // 41
.then(v => console.log(v));
console.log('this prints FIRST'); // proves non-blocking + async callbacks
Two details carry the whole design. First, settle bails out if the state isn't PENDING — that one line enforces immutability. Second, then returns a new promise whose resolution is wired to the handler's return value; that's why chains flow values downstream and a single .catch at the end can absorb an error thrown anywhere upstream.
Python implementation
Python's asyncio exposes futures directly, and the producer/consumer split is explicit. This mirrors the same lifecycle — create pending, resolve elsewhere, await without blocking the loop:
import asyncio
async def main():
loop = asyncio.get_running_loop()
fut = loop.create_future() # pending future (the read end)
# A producer settles it later — here after a simulated 0.5s of work.
def producer():
if not fut.done(): # settle-once guard
fut.set_result(42)
loop.call_later(0.5, producer)
# Non-blocking callback (like .then) — fires when the future settles:
fut.add_done_callback(lambda f: print("callback got", f.result()))
print("this prints first — we did not block")
value = await fut # suspend THIS coroutine, not the loop
print("awaited value:", value) # 42
asyncio.run(main())
# Compose many: gather is Python's Promise.all
async def fan_out():
async def fetch(n):
await asyncio.sleep(0.1) # overlapping I/O, not 3x serial
return n * 10
results = await asyncio.gather(fetch(1), fetch(2), fetch(3))
print(results) # [10, 20, 30] in ~0.1s, not 0.3s
The key line is await fut: it suspends only the current coroutine and hands control back to the event loop, which is free to run other tasks. Nothing blocks. For thread-pool work, loop.run_in_executor returns a future that settles when the pool thread finishes — the same abstraction bridging blocking code into the async world.
Variants and the operators worth knowing
Promise.all vs allSettled vs race vs any. These four combinators cover the common fan-in shapes:
await Promise.all([a, b, c]); // all results, but rejects on FIRST failure
await Promise.allSettled([a, b, c]); // never rejects; [{status,value|reason}, ...]
await Promise.race([a, b, c]); // first to SETTLE wins (fulfill OR reject)
await Promise.any([a, b, c]); // first to FULFILL wins; rejects only if all fail
Deferred. An older idiom (jQuery, Q) that hands you the resolve/reject functions outside the executor, so you can settle the promise from anywhere. ES2024 standardized it as Promise.withResolvers(), which returns { promise, resolve, reject } together.
Cold (lazy) futures. Rust's Future and Scala's IO (Cats Effect) don't start work until driven. This makes them composable as pure descriptions and trivially cancellable — drop the future and nothing leaks. The trade-off is you must remember to run them.
async/await. Not a different primitive — sugar over promises. An async function returns a promise; await p is p.then(...) with the continuation written as the rest of the function body. The bytecode literally suspends and resumes the function around the same microtask machinery.
Promise pipelining. In capability systems like Cap'n Proto and the original E language, you can call a method on the result of a promise before it resolves, batching dependent remote calls into a single round trip — a powerful latency win over the network.
Common bugs and edge cases
- Forgetting to return inside
.then..then(() => { doAsync(); })doesn't wait fordoAsync— you dropped its promise. Write.then(() => doAsync())so the chain links to it. - Swallowed rejections. A chain without a trailing
.catch(or atry/catcharoundawait) silently loses errors and triggers an unhandled-rejection crash in modern Node. - Sequential await in a loop.
for (const u of urls) await fetch(u)runs requests one at a time. Useawait Promise.all(urls.map(fetch))to overlap them — often a 10× wall-clock win. - Assuming
.thenruns synchronously. Even on an already-resolved promise, the callback is deferred to a microtask. Code written assuming immediate execution breaks subtly. - Mixing up
allandallSettled. If one of ten requests may fail and you still want the other nine,Promise.allthrows everything away on the first rejection — reach forallSettled. - No cancellation. A promise can't be cancelled; the underlying work keeps running even after you stop caring. Pair it with an
AbortControllerif you need to abort an in-flight fetch. - Returning a promise from an executor expecting it to "unwrap." Resolving a promise with another promise chains them (the spec's "thenable" assimilation), but resolving with a plain object that merely has a
thenmethod also triggers assimilation — an accidental footgun if your data object happens to have a field namedthen.
Frequently asked questions
What's the difference between a future and a promise?
They're two ends of the same channel. The promise is the write end — the producer calls resolve() or reject() on it exactly once. The future is the read end — the consumer reads the value or attaches callbacks but can't set it. JavaScript merges both into one Promise object; C++ std::promise/std::future and Java keep them separate.
Does a promise block the calling thread?
No. Creating a promise and attaching .then returns immediately; the calling thread keeps running. Blocking only happens if you explicitly wait — for example C++ future.get() or Python future.result(), which park the thread until the value arrives. JavaScript has no blocking wait at all; you must use .then or await.
What happens if you resolve a promise twice?
The first resolve or reject wins and freezes the promise forever; every later call is silently ignored in JavaScript. A settled promise is immutable, which is exactly why subscribing after it resolved still delivers the value. In C++, calling set_value on an already-satisfied std::promise throws std::future_error instead.
Why does .then run asynchronously even when the promise is already resolved?
The spec guarantees callbacks always fire on a later turn of the event loop — the microtask queue — never synchronously inside .then. This removes the "releases Zalgo" class of bug where a function is sometimes sync and sometimes async, which makes ordering unpredictable. Your code after .then always runs before the callback.
How is async/await related to promises?
async/await is pure syntactic sugar over promises. An async function always returns a promise, and await pauses the function until a promise settles, then resumes with its value — equivalent to a .then continuation but written as straight-line code. There is no new mechanism underneath; await is .then with the callback hidden.
What is the difference between Promise.all and Promise.race?
Promise.all waits for every promise and resolves with an array of results, but rejects as soon as any one rejects. Promise.race settles as soon as the first promise settles, fulfilled or rejected. Promise.allSettled never short-circuits — it waits for all and reports each outcome; Promise.any resolves on the first success and only rejects if all fail.