The question this answers
What does a handle to unfinished work actually promise me, and who is doing the work while I hold it?
A request handler that kicks off a currency-rate lookup, does some local formatting, and then needs the rate — holding a handle to the lookup in between.
The shared state *is* the handle: one slot holding {pending | value | error}, plus a list of continuations to run when it settles. Producer and consumer both touch it; in a threaded implementation that slot needs synchronization, on an event loop it does not.
The handle settles at most once. Every consumer that observes it sees the same value or the same error, and no consumer observes it as pending after it has settled.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
The same handle in four languages — and four different bargains
Every one of these is "a box that will contain a result", and every one of them disagrees about who computes it, when computation starts, and whether the box can be read more than once. Those disagreements are the reason porting async code between these languages goes badly.
The two axes worth holding in your head: eagerness (does creating the handle start the work?) and multiplicity (can more than one consumer take the result?). JavaScript is eager and multi-consumer. std::future is eager-ish — whatever produced it is already running — and strictly single-consumer, because get() moves the value out. Python coroutines are lazy and only become concurrent when wrapped in a Task; asyncio.Future itself is eager and multi-consumer like a promise.
1// std::async: the runtime picks a thread (or defers). Real parallelism.2std::future<double> f = std::async(std::launch::async, fetch_rate, "EUR");3 4format_local_parts(); // runs while fetch_rate is on another thread5 6double rate = f.get(); // blocks this thread until ready; rethrows on error7// f.get() may be called ONCE. f is now invalid.8// For many consumers: std::shared_future<double> sf = f.share();A real thread is doing the work (with launch::async). get() blocks the calling thread — it does not yield to a scheduler. The value is moved out, so the future is single-consumer; a destructing future from std::async blocks in its destructor, which surprises everyone once.
1// Eager: fetchRate() is already in flight before this line returns.2const p = fetchRate('EUR')3 4formatLocalParts() // runs while the request is on the network5 6const rate = await p // suspends this task; the loop runs other work7// p can be awaited any number of times, by anyone, forever.8// p.then(...) after it settles still fires — on a microtask.The work started at construction. await suspends the task rather than blocking a thread, and the promise is a broadcast: every consumer sees the same settled value. A rejection with no handler attached becomes an unhandled rejection.
1// The type says "a rate, later" — it says nothing about who computes it.2const p: Promise<number> = fetchRate('EUR')3 4// Promise<T> erases failure entirely: the rejection type is unknown.5// If errors matter, model them in the value:6type Result<T> = { ok: true; value: T } | { ok: false; error: RateError }7const safe: Promise<Result<number>> = fetchRate('EUR')8 .then((value) => ({ ok: true as const, value }))9 .catch((error: RateError) => ({ ok: false as const, error }))TypeScript types the success channel and nothing else — Promise<number> and a promise that always rejects have the same type. Pushing failure into the value is how you make the compiler help you at a fan-in point.
1# Lazy: calling the coroutine function runs NOTHING.2coro = fetch_rate("EUR") # no request has been made3 4task = asyncio.create_task(coro) # NOW it is scheduled on the loop5format_local_parts()6 7rate = await task # suspends this coroutine8# asyncio.Future/Task may be awaited by several coroutines; all get the value.9# Never awaited and it fails? "Task exception was never retrieved" at GC time.The single biggest porting hazard: a coroutine object is inert. Code translated from JavaScript that "starts" three requests and awaits them later runs them strictly sequentially unless each one is wrapped in create_task or handed to gather.
- Eagerness: JS promises and std::async futures are already running; a Python coroutine object has done nothing until scheduled. Translating JS fan-out to Python without create_task silently serialises it.
- Consumption: std::future::get() moves the value and may be called once (use shared_future for many); JS promises and asyncio Futures are broadcast and re-readable forever.
- Blocking versus suspending: future::get() parks an OS thread; await parks a task and frees the executor. Calling a blocking get on an event loop is how you stall a whole server.
- Failure with no consumer: C++ stores the exception until someone calls get(); JS raises an unhandled rejection; asyncio logs "exception was never retrieved" at collection time. Three different ways to lose an error.
- Cancellation: asyncio Tasks are cancellable and the cancellation arrives at the next await; std::future has no cancellation at all; a JS promise cannot be cancelled, only ignored — which is why AbortController exists beside it.
Pending → settled, once and forever
The state machine is small and the constraints on it are what make the abstraction safe to share. A handle starts pending. It transitions exactly once, to fulfilled with a value or rejected with an error. After that it is immutable: late subscribers get the settled result immediately rather than waiting, and an early subscriber and a late one cannot disagree.
That immutability is the reason a promise is safe to hand to several consumers even though it is shared mutable state underneath. The only mutation is the one-way pending → settled transition, protected by the producer's own synchronization (a mutex in std::promise, the loop's single-threadedness in JS). Everything a consumer does is a read plus a registration. This is Safe Publication: Handing Over a Finished Object in miniature.
The trap is that "settled" says nothing about "observed". A rejected promise with no .catch attached at the moment of rejection is a live error nobody is holding, and each runtime handles that differently and badly. Attaching the handler in the same tick you create the promise is the discipline; void p.catch(log) is the ugly, correct idiom.
Two consumers, one handle: what is and is not a race
Sharing one handle between two consumers is safe *for the handle*. It is not automatically safe for what the consumers then do, and the failure below is the one that shows up in caching layers: two callers await the same promise, both resume, and both act as if they were the only one.
Note the second half of the schedule, which is the part people miss: the handle settling is a single event, but the *continuations* run as separate steps, in registration order, with the loop free to interleave nothing between them on JS but free to interleave anything between them in a threaded implementation. Rely on the ordering only where the runtime actually promises it.
| # | Caller A | Caller B | Shared handle | State |
|---|---|---|---|---|
| 1 | creates the handle: fetchRate('EUR') starts | · | · | handle=pending fetches=1 served=0 |
| 2 | registers its continuation and suspends | · | · | handle=pending fetches=1 served=0 |
| 3 | · | finds the same handle in the in-flight map and registers its continuation | · | handle=pending fetches=1 served=0 |
| 4 | · | · | settles: rejected with a timeout | handle=rejected fetches=1 served=0 |
| 5 | resumes, sees the rejection, removes the handle from the in-flight map, retries | · | · | handle=rejected fetches=2 served=0 |
| 6 | · | resumes, sees the same rejection, removes the handle again — the entry it deletes is now A's new one | · | handle=rejected fetches=2 served=0 ✕ B evicted A's fresh in-flight handle. The next caller starts a third fetch, and under load this degenerates into one fetch per caller — the coalescing silently stops working exactly when it is needed. |
| 7 | · | retries as well | · | handle=rejected fetches=3 served=0 |
Key points
- A future or promise is a handle with exactly two states, pending and settled, and the transition happens once and is irreversible.
- Immutability after settling is what makes it safe to share: consumers only read and register, so late subscribers cannot disagree with early ones.
- Eagerness differs by language: JS promises and
std::asyncfutures are already running; a Python coroutine object has done nothing until it is scheduled. std::future::get()blocks an OS thread and consumes the value once;awaitsuspends a task and the value stays readable by everyone.- A settled-with-error handle nobody is watching is lost differently in each runtime — unhandled rejection, "never retrieved", or an exception stored until a
get()that never comes. - Sharing a handle coalesces the *work*, never the *reactions*: each consumer runs its own continuation, so any cleanup there must be conditional.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • A producer side (
std::promise, a resolver function,loop.create_future()) and a consumer side (the future/promise object) are created as a pair over one shared slot. - • The slot holds a state tag, a value-or-error union, and a list of continuations registered while pending.
- • Consumers either block on the slot (
get()), or register a continuation and yield (await,.then). - • The producer writes the value or error and flips the state tag under whatever synchronization the implementation uses — a mutex plus condition variable in threaded implementations, nothing at all on a single loop.
- • Registered continuations are then scheduled: as microtasks in JS, as loop callbacks in asyncio, as the unblocking of a condition variable in C++.
- • Registrations arriving after settling are scheduled immediately rather than queued, which is why the abstraction has no lost-wakeup problem for the consumer.
- • A registers while pending; the handle settles; B registers after settling — both observe the same value, B's continuation simply runs sooner relative to its registration. No race.
- • A creates the handle, B finds it in an in-flight map, the handle rejects, both continuations delete the map entry, and B deletes A's replacement — the coalescing collapses under exactly the load it exists for.
- • A
std::promiseis destroyed withoutset_value; the future settles withbroken_promiseand every waiting thread wakes with an exception rather than hanging — the designed-in escape from a lost wakeup. - • A JS promise rejects with no handler attached in that tick; a
.catchadded a tick later still receives it, but the runtime has already emitted an unhandledRejection — the handler was correct and the alert fired anyway. - • CPython: three coroutine objects created and awaited in sequence; because nothing was wrapped in
create_task, the "concurrent" fan-out ran strictly one after another and the only symptom is that it took three times as long.
- • Guaranteed: at most one settlement. Later
resolve/rejectcalls are ignored (JS) or throwpromise_already_satisfied(C++). - • Guaranteed: every consumer observes the same outcome; the handle cannot appear fulfilled to one and pending to another after settling.
- • Guaranteed: a continuation registered after settling still runs — no lost wakeup on the consumer side.
- • NOT guaranteed: that anything is running. A handle is a slot, not a scheduler; nobody has to be computing the value.
- • NOT guaranteed: cancellation.
std::futurehas none, a JS promise cannot be cancelled, and asyncio cancellation only lands at the next suspension point. - • NOT guaranteed: that errors reach anyone. Each runtime loses an unobserved rejection in its own way.
- • NOT guaranteed: multi-consumption in C++ —
get()moves the value out and the second call is undefined behaviour unless you usedshared_future.
- • The slot is contended only at settlement, and only in threaded implementations; the mutex in
std::promiseis held for the duration of one write. - • The real cost in C++ is
get()parking an OS thread — that thread is unavailable for anything until the value lands, which is the whole reason event loops exist. - • On a loop, thousands of continuations registered on one popular handle all become ready in the same tick, so settlement produces a burst rather than a trickle. That is Thundering Herd wearing promise syntax.
- • Long continuation chains hold memory: every pending
awaitin the chain is a live heap frame, so a slow dependency inflates memory in proportion to in-flight requests.
- • Unobserved error: rejection with no handler,
Task exception was never retrieved, or an exception sitting in a future whoseget()is never called. - • Double consumption: calling
std::future::get()twice — undefined behaviour, not an exception. - • Broken promise: the producer is destroyed without setting a value; consumers get an exception rather than a hang, but only because the standard says so.
- • Blocking the executor:
future.get()orasyncio.run_until_completecalled from inside a running loop, stalling every other task. - • Accidental serialisation: lazy coroutines awaited in sequence when the author believed they were concurrent.
- • Shared-handle cleanup race: two continuations both tearing down an in-flight entry, evicting a newer one.
- • Decoupling "start the work" from "need the result", so unrelated local work fills the gap.
- • Fan-out: collecting many handles and waiting on them together (Promise.all & gather).
- • Coalescing duplicate work — storing one handle in a map is the entire implementation of Single-Flight Coalescing.
- • Crossing an execution boundary cleanly: a worker thread's result arrives as a handle rather than as a callback plus a mutex.
- • When the result is needed on the very next line: the handle adds allocation and scheduling for no overlap at all.
- • When failure handling is an afterthought — an unobserved rejection is worse than a synchronous throw because it is silent.
- • When the handle is a lazy coroutine and the author assumed eagerness; the code is correct and needlessly serial.
- • When
get()is called on a thread that must stay responsive, which converts an async design back into a blocking one.
- • Unhandled-rejection and "never retrieved" counters — both are cheap to instrument and both are usually absent from dashboards.
- • Number of live pending handles over time; a monotonic climb is a leak of in-flight work, not a slow dependency.
- • Settle latency per creation site, so you can tell "the dependency is slow" from "the continuation waited for the loop".
- • In C++, thread-pool queue depth alongside blocked-on-
get()thread count; the second number is your real parallelism ceiling. - • For the coalescing case specifically: fetches issued divided by callers served. It should be far below one, and it silently returns to one when the cleanup race fires.
- • Two representations of "unfinished work" now exist in your codebase — the handle and the task that fills it — and confusing them produces the eagerness bugs.
- • Error propagation is no longer lexical; a failure surfaces wherever someone happens to observe the handle, which may be a different module entirely.
- • Lifetime questions appear: who owns the handle, who is allowed to cancel it, and what happens to the work if everyone drops it.
- • Cross-language teams must hold three different eagerness models simultaneously, and the failure is silent serialisation rather than an error.
- • Just call the function synchronously when the result is needed immediately — a handle you await on the next line buys nothing.
- • A callback with an explicit error parameter, when the toolchain has no async support; less composable, but no hidden state machine.
- • A channel or queue when there will be *many* results rather than one — a future models exactly one settlement and modelling a stream with it is where the abstraction breaks. See Channels.
- • A blocking thread with a real thread pool, when the language's ecosystem is synchronous and the concurrency need is modest — Thread Pools.
What people believe, and what is true
A promise means something is running.
A promise is a slot with a state tag. Whether anything computes the value depends entirely on what created it — and in Python, a bare coroutine object is running nothing at all.
Two callers awaiting the same promise means the work happens once, so everything happens once.
The work happens once; every continuation still runs. Non-idempotent cleanup inside a continuation runs once per caller.
future.get() and await are the same thing with different spelling.
One parks an OS thread and consumes the value; the other parks a task, frees the executor, and leaves the value readable forever.
Go deeper
Overview
A handle to a result that is not ready: pending, then permanently a value or an error.
Practical
Attach the error handler in the same tick you create the handle. Know your language's eagerness before assuming a fan-out is concurrent. Make continuation-side cleanup conditional on identity.
Advanced
The handle is shared mutable state whose only mutation is a one-way transition, which is why it needs no consumer-side locking — a small, exact instance of safe publication.
Internals
A typical implementation is a control block with a state word, a storage union and an intrusive continuation list. Threaded implementations settle under a mutex and signal a condition variable; loop-based implementations flip the tag and push continuations onto the microtask queue, which is why JS needs no locking here at all.