Concurrency Roadmap/Coordination Primitives

Futures, Promises & Task-Based Concurrency

A future is a placeholder for a result that doesn't exist yet; a promise is the write side that eventually fills it in — and the interview-relevant skill is composing multiple futures (chaining, combining, racing) while exceptions and cancellation propagate correctly through the chain.

!!!3/5Theory: 30m2 problems
Language-specific mechanics: Concurrency Language Manual — Channels & Message-Passing Concurrency

A placeholder for a value that doesn't exist yet

A future is a handle to a result that will exist at some point, produced by work running elsewhere (another thread, another process, a network call). At the moment you receive the future, the value usually isn't ready — but the future object itself exists immediately, and you can ask it "are you done," block until it is, or (in richer APIs) attach a continuation that runs automatically once it is.

A promise is the write side of that same relationship: the object that eventually fulfills the future with a value, or fails it with an error. In languages that keep the two roles separate, a promise and its future are two different objects handed to two different parties — the producer holds the promise, the consumer holds the future. JavaScript's Promise is a slight naming curiosity here: the single object it gives you plays both roles simultaneously — you get resolve/reject closures (the promise/write side) inside the constructor's executor function, and the object itself is what callers consume (the future/read side). Java's original Future (from Java 5) was read-only and not composable at all; CompletableFuture (Java 8) fused the two roles into one class, adding complete()/completeExceptionally() as the write side. Kotlin's Deferred is the read side, produced by the async coroutine builder, with CompletableDeferred available when you need to construct and complete one manually — the cleanest example of a real separate promise/future pair in the languages this roadmap covers.

Two ways to consume a future

Blocking-get. Call .get()/.join()/.result() and the calling thread parks until the value (or exception) is available. Simple to reason about, but it ties up a thread for the entire wait — which defeats a large part of the point of doing the work asynchronously in the first place. Note that this option doesn't even exist for JavaScript's Promise: a single-threaded event loop that blocked on a pending promise would deadlock itself, so Promise deliberately only offers the next option.

Callback / then-style composition. Register a continuation — .thenApply(), .thenAccept(), .then() — that the runtime invokes automatically once the future settles, without parking the calling thread. The continuation runs later, on whatever executor the future implementation chooses (a thread pool for CompletableFuture, the microtask queue for JS Promise). This is what makes futures composable: every .then()-style call returns a new future, so you can chain, branch, and combine without ever blocking.

async/await is worth naming here even though it's not this subtopic's job to teach it (that's topic 10, Async & Event-Driven Concurrency): it is purely syntactic sugar over the callback-style model above, letting you write code that looks sequential while the compiler/runtime rewrites it into the same chain of continuations under the hood. Everything in this subtopic — the future/promise object, its states, and how you compose several of them — is the substrate that async/await sits on top of.

Composing futures: the part interviewers actually probe

Sequencing (chaining). Transforming a future's eventual value with a synchronous function (thenApply in Java, a .then() handler that returns a plain value in JS) is straightforward. It gets interesting when the transformation itself is asynchronous — i.e., it returns another future. Applying a synchronous-style transform there naively gives you a future-of-a-future, which is almost never what you want. Java solves this with a dedicated method, thenCompose, that flattens the nesting; JavaScript's .then() handles both cases with the same method by auto-flattening any thenable you return from inside it. Knowing the name thenCompose and being able to say "it's flatMap, thenApply is map" is a very standard interview beat.

Combining independent futures. Two futures that don't depend on each other (fetch a user, fetch their orders) can run concurrently and be joined once both finish: thenCombine in Java, a manual awaitAll over multiple Deferred in Kotlin, Promise.all in JS. Worth memorizing as a concrete API-shape difference: Java's CompletableFuture.allOf(...) returns CompletableFuture<Void> — you still have to reach back into each original future individually to get its value — while JavaScript's Promise.all([...]) directly resolves to an array of the results, in order. Both are fail-fast: the first rejection short-circuits the combined result. Promise.allSettled (and the equivalent of gathering results manually in Java/Python) is the "wait for everyone regardless of failures" alternative.

Racing. Promise.race() / CompletableFuture.anyOf() resolve with whichever input future settles first — the standard building block for implementing a timeout: race your real computation against a future that rejects after a fixed delay.

Exception propagation through a chain. An exception raised at any stage of a chain skips every subsequent transformation stage (thenApply, thenCompose, .then()'s success handler) until it reaches a stage specifically designed to intercept failures — exceptionally/handle in Java, .catch() (or the second argument to .then()) in JS. This mirrors synchronous try/catch propagating up a call stack, except it's happening across asynchronous continuations instead of stack frames. Java wraps the original cause in a CompletionException (or ExecutionException if you're blocking with get()), so "unwrap the cause" is a real detail worth knowing, not trivia.

Cancellation. This is where the languages diverge the most, and it's a favorite "how deep do you actually understand this" question:

  • Java's original Future.cancel(mayInterruptIfRunning) is best-effort — it can set a thread interrupt flag, but it cannot force a computation that ignores interruption to actually stop.
  • CompletableFuture.cancel() doesn't even do that much: it just completes the future exceptionally with a CancellationException. It does not interrupt whatever thread is actually running the underlying computation.
  • Kotlin's structured concurrency has the most coherent story here: cancelling a parent Job cooperatively propagates CancellationException into every child coroutine at their next suspension point, and well-behaved coroutine code is expected to check for cancellation or let it propagate rather than swallowing it.
  • Plain JavaScript Promises have no built-in cancellation at all — once created, a promise runs to completion. Real cancellation requires an external mechanism (an AbortController/AbortSignal threaded through the operation) that the async work has to explicitly check.

Interview gotchas

  • A future doesn't parallelize anything by itself. Wrapping a computation in a future only decouples when it's produced from when it's consumed — the computation still has to actually run on some thread/coroutine/executor. Forgetting this leads to "why didn't this get faster" confusion.
  • thenApply vs. thenCompose nesting bugs — passing a function that returns a future to thenApply produces CompletableFuture<CompletableFuture<T>>, a classic and very common mistake.
  • Silently dropped exceptions. If you never attach an exceptionally/.catch() handler, the failure doesn't disappear — it surfaces (wrapped) the moment something finally calls get()/join(). In JavaScript specifically, an unhandled promise rejection doesn't get "caught" anywhere; it triggers a runtime warning (and can crash a Node process) rather than propagating anywhere useful.
  • Blocking inside a continuation that runs on a shared pool. CompletableFuture's default continuations run on the common ForkJoinPool unless you specify an executor — doing blocking I/O inside one can starve unrelated asynchronous work across the entire JVM.
  • Choosing the wrong combination semantics. Defaulting to fail-fast (allOf/Promise.all) when you actually need every result regardless of individual failures (Promise.allSettled, or manually catching per-future in Java/Python) silently discards partial progress.

Where this leads

This subtopic covered the future/promise object and how to compose several of them explicitly. Two things build directly on it: topic 9 (Thread Pools & Executors) is what actually produces most futures in practice — submitting a task to an executor is what hands you back the future you've been composing here — and topic 10 (Async & Event-Driven Concurrency) shows how async/await and the event-loop model let you express the same compositions with sequential-looking syntax instead of explicit chaining.

Reference implementations in:

Creating a Future and Consuming It: Blocking-Get vs. Callback

The same asynchronous computation, consumed two ways — the callback style is what makes futures composable.

// No Future/Promise type — a single-value channel *is* the future. resultCh := make(chan int, 1) go func() { resultCh <- slowLookup(id) }() blocking := <-resultCh // the blocking-get analogue // "Callback" style: don't block the caller — another goroutine consumes. done := make(chan int, 1) go func() { done <- slowLookup(id) }() go func() { render((<-done) * 2) }()

Go has no Future in the standard library; a buffered channel of one result (often struct{ V T; Err error }) is the whole pattern. There is no .then() chaining — composition is more goroutines and more channel receives. Buffer the result channel so the producer doesn't leak if nobody receives.

Composing Futures: Combine, Chain, and Propagate Errors

Run two independent lookups in parallel, combine their results, and let a failure in either short-circuit the whole chain.

g, ctx := errgroup.WithContext(context.Background()) var user User var orders []Order g.Go(func() error { var err error user, err = fetchUser(ctx, id) return err }) g.Go(func() error { var err error orders, err = fetchOrders(ctx, id) return err }) if err := g.Wait(); err != nil { receipt = failedReceipt(err) } else { receipt = buildReceipt(user, orders) }

There is no thenCombine. Two result channels plus a receive of each is the stdlib shape; errgroup.Group (golang.org/x/sync/errgroup, not stdlib) is the usual upgrade because the first error cancels ctx for the sibling. A bare WaitGroup waits for both but does not cancel the other on failure — that's the interview distinction.

Further Resources (Optional)

Practice Problems

Interview relevancy:!!!Critical — must-have!!Important — highly recommended!Good to have~Niche — rarely asked

Optional Practice (Extra Reps)

For once you've cleared the main set above and want more reps on this pattern. These don't count toward the roadmap's progress stats — solve them purely for your own benefit.