Knowledge Guide
HomeHands-On BuildsConcurrency Katas

Build a Future / Promise

Build a Future / Promise

Every async API you've ever called — an HTTP client, a database driver, a thread pool's submit() — hands you back a value that doesn't exist yet. Thread Pools, Executors & Futures told you a Future is "a handle to a result that isn't ready yet"; this lab makes you build that handle from an empty file, in Java and Go, and break it the way real codebases break it — not with a lock you forgot, but with a callback you registered one instruction too late. If you've done Build a Thread Pool, you already used a bare-bones Future in its M4; this lab is the deep dive on the Future/Promise contract itself: the state machine, the completion race, and the callback chaining (thenApply) that turns a Future into the foundation of every reactive/async framework you'll touch.

1. The trap

You need to kick off work on another thread and get its result back later. Two "obvious" first drafts, both wrong in ways that don't show up in a quick manual test:

public final class Drafts {

    static Object computeExpensiveThing() { return new Object(); }

    // Draft A -- just block on a shared variable
    static void draftA() throws InterruptedException {
        Object[] resultBox = new Object[1];
        new Thread(() -> { resultBox[0] = computeExpensiveThing(); }).start();
        while (resultBox[0] == null) { /* wait... */ }      // busy-wait: burns a full core
        Object result = resultBox[0];                          // and it's a data race: no happens-before edge at all
    }

    // Draft B -- a shared "done" flag, checked in a loop
    static volatile boolean done = false;
    static Object result;
    static void draftB() throws InterruptedException {
        new Thread(() -> { result = computeExpensiveThing(); done = true; }).start();
        while (!done) { Thread.sleep(1); }                    // better than spinning, still polling
        Object r = done ? result : null;                        // "done" is volatile, but is `result` guaranteed visible?
    }

    public static void main(String[] args) throws InterruptedException {
        draftA();
        done = false;
        draftB();
    }
}

Draft A is two bugs at once: a busy-wait that pins a core at 100% doing nothing useful, and a genuine data race — nothing establishes a happens-before edge between the writer thread setting resultBox[0] and the reader thread's repeated reads, so the compiler/JIT/CPU are free to cache the read forever and the loop can spin indefinitely even after the value is set. Draft B is more polite (it sleeps instead of spinning) but it's still polling: latency is bounded by your sleep interval, throughput is wasted on wake-ups that find nothing changed, and if you have N callers all waiting on the same computation, that's N separate poll loops burning cycles for one answer. Worse: done being volatile guarantees ordering for done itself, but the moment you add a second field (result) you're relying on the JMM's happens-before-via-volatile rule holding across two fields written by one thread and read by another — easy to get subtly wrong, easy to "fix" by sprinkling more volatile around code that should have used one synchronization primitive from the start.

What you actually want: block without spinning (sleep the calling thread, get woken exactly when the value exists), one write, many readers (multiple callers can all wait on the same pending result), and a way to say "and when it's ready, do X" without the caller having to ask at all. That object is a Future (the reader's handle) paired with a Promise (the writer's handle to complete it) — in most JVM/Go designs the two are fused into one type, which is what you'll build below.

2. Scope it like a senior

Before writing a line, pin down the contract:

Answering these up front is why java.util.concurrent.CompletableFuture and Go's errgroup/channel idioms look the way they do — they're not arbitrary APIs, they're the direct consequence of these five questions.

3. Reason to the design

Attempt 0 — a shared flag, polled. Movement 1's Draft B. Wrong for the reasons above: wasted cycles, bounded-by-sleep-interval latency, and a fragile multi-field happens-before story. Reject it and go straight to the primitive built for exactly this: a lock plus a condition variable (you built this in Condition Variables and in the Bounded Producer–Consumer lab — same mechanism, one waiter-side event instead of a queue).

Attempt 1 — a one-shot slot with a state machine. Model the Future as three states: PENDING → COMPLETED or PENDING → FAILED. Never any other transition — once you leave PENDING you're done forever. get() acquires the lock, loops while (state == PENDING) condition.await() (the exact same "loop, don't trust a one-shot wake" rule from the producer–consumer lab — spurious wakeups are still possible here), then reads the value or throws the wrapped exception. complete(value) acquires the lock, and here is the first design decision that isn't just "condition variable, again": it must check the current state before writing. If state is already COMPLETED/FAILED, complete() must be a no-op (return false), not a second write. Two threads racing to complete the same Future is normal — e.g. two redundant requests racing to populate one cache-fill Future — and exactly one of them should "win," deterministically, with the loser finding out cheaply.

Attempt 2 — add callbacks, and hit the actual race this lab is named for. get() alone can't build a non-blocking pipeline; you need whenComplete(callback) so a caller can say "run this when you're done" and walk away. The naive implementation:

import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;

final class NaiveFuture<T> {
    static final int PENDING = 0, COMPLETED = 1, FAILED = 2;
    int state = PENDING;
    T value;
    Throwable error;
    List<BiConsumer<T, Throwable>> callbacks = new ArrayList<>();

    void whenComplete(BiConsumer<T, Throwable> cb) {
        if (state == PENDING) {
            callbacks.add(cb);            // register for later
            return;
        }
        cb.accept(value, error);          // already done -- fire now
    }
}

looks right, and the logic is right — the bug is where the lock goes. If the state == PENDING check and the callbacks.add(cb) happen in separate critical sections (or no critical section at all), you've created a window: thread A checks state, sees PENDING, and is about to enqueue — but before it does, thread B calls complete(), which runs to completion and fires every callback currently in the list. Thread A's callback isn't in the list yet. It enqueues a moment later, into a list nobody will ever drain again. The callback is silently lost — not delayed, not out of order, gone. This is the register-after-complete race, and it's the single most common bug in hand-rolled Future/Promise implementations, because "check state, then act" reads like it should be atomic and isn't unless you make it so.

The fix, and the whole lesson of this lab: the state check and the enqueue-or-fire decision must be one atomic step, guarded by the same lock that guards complete(). Either complete() runs first (fully) and whenComplete() sees state != PENDING and fires immediately, or whenComplete() runs first (fully, enqueuing under the lock) and complete() sees the callback already in the list and fires it. There is no third interleaving, because the lock forbids it. Fire the callback itself outside the lock (never run arbitrary user code while holding your internal lock — that's how you get reentrancy bugs and lock-ordering deadlocks), but make the decision of whether to fire-now-or-enqueue happen fully inside it.

4. Build it — milestones

Build the Future with this contract, growing it milestone by milestone. Attempt each yourself before reading the reference implementation below — the reveal is positioned after the milestones on purpose.

Reference implementation — Java (ReentrantLock + Condition, hand-rolled)

Not a wrapper around CompletableFuture — every line below is the actual mechanism such a class would need internally. The comment on whenComplete is Movement 3's fix, spelled out in code: the state read and the enqueue-or-fire decision share one lock acquisition.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import java.util.function.Function;

/** A hand-rolled one-shot Future/Promise: PENDING -> COMPLETED/FAILED.
 *  get() blocks until set; complete()/completeExceptionally() set the result
 *  exactly once and wake every waiter; whenComplete() registers a callback
 *  that fires now if already done -- that's the race this file is about. */
public final class MyFuture<T> {
    private enum State { PENDING, COMPLETED, FAILED }

    private final ReentrantLock lock = new ReentrantLock();
    private final Condition doneCond = lock.newCondition();
    private State state = State.PENDING;
    private T value;
    private Throwable error;
    private final List<BiConsumer<T, Throwable>> callbacks = new ArrayList<BiConsumer<T, Throwable>>();

    /** Sets the result exactly once. Returns false if already completed/failed (M2: double-complete guard). */
    public boolean complete(T v) {
        List<BiConsumer<T, Throwable>> toRun;
        lock.lock();
        try {
            if (state != State.PENDING) return false;   // idempotent: second caller loses, cleanly
            value = v;
            state = State.COMPLETED;
            toRun = new ArrayList<BiConsumer<T, Throwable>>(callbacks);
            callbacks.clear();
            doneCond.signalAll();
        } finally {
            lock.unlock();
        }
        for (BiConsumer<T, Throwable> cb : toRun) cb.accept(v, null);   // fire OUTSIDE the lock
        return true;
    }

    public boolean completeExceptionally(Throwable t) {
        List<BiConsumer<T, Throwable>> toRun;
        lock.lock();
        try {
            if (state != State.PENDING) return false;
            error = t;
            state = State.FAILED;
            toRun = new ArrayList<BiConsumer<T, Throwable>>(callbacks);
            callbacks.clear();
            doneCond.signalAll();
        } finally {
            lock.unlock();
        }
        for (BiConsumer<T, Throwable> cb : toRun) cb.accept(null, t);
        return true;
    }

    /** Blocks until the result (or failure) is available. */
    public T get() throws InterruptedException, ExecutionException {
        lock.lock();
        try {
            while (state == State.PENDING) doneCond.await();
            if (state == State.FAILED) throw new ExecutionException(error);
            return value;
        } finally {
            lock.unlock();
        }
    }

    /** M4: bounded wait instead of blocking forever. */
    public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {
        long nanos = unit.toNanos(timeout);
        lock.lock();
        try {
            while (state == State.PENDING) {
                if (nanos <= 0L) throw new TimeoutException();
                nanos = doneCond.awaitNanos(nanos);
            }
            if (state == State.FAILED) throw new ExecutionException(error);
            return value;
        } finally {
            lock.unlock();
        }
    }

    /** Registers a callback. THE RACE this whole lab is about: the check
     *  ("are we done?") and the enqueue/fire decision happen in ONE lock
     *  acquisition, so a concurrent complete() can never land in between. */
    public void whenComplete(BiConsumer<T, Throwable> cb) {
        boolean alreadyDone;
        T v = null;
        Throwable e = null;
        lock.lock();
        try {
            alreadyDone = state != State.PENDING;
            if (alreadyDone) { v = value; e = error; }
            else callbacks.add(cb);
        } finally {
            lock.unlock();
        }
        if (alreadyDone) cb.accept(v, e);   // fire immediately -- must be outside the lock
    }

    /** Chains a transformation without blocking the caller. */
    public <U> MyFuture<U> thenApply(Function<T, U> fn) {
        final MyFuture<U> next = new MyFuture<U>();
        whenComplete((v, e) -> {
            if (e != null) { next.completeExceptionally(e); return; }
            try { next.complete(fn.apply(v)); }
            catch (Throwable ex) { next.completeExceptionally(ex); }
        });
        return next;
    }
}

Reference implementation — Go (done channel + mutex-guarded callback list)

Go's version leans on a fact worth stating precisely: closing a channel happens-before every receive from it returns (part of the Go memory model). That means every field written before close(f.done) — guarded by the same mutex — is safely visible to every goroutine that unblocks from <-f.done, with no separate memory fence needed on the read side. Get() itself needs no lock at all.

// Package futurelab is a hand-rolled Future/Promise: a one-shot result slot,
// PENDING -> COMPLETED/FAILED, that blocks Get() and fires callbacks -- with
// the register-after-complete race handled by design (see future_test.go).
package futurelab

import (
	"context"
	"sync"
)

type state int32

const (
	pending state = iota
	completed
	failed
)

// Future is a one-shot result slot. done is closed exactly once, on
// completion; Get() blocks by receiving from it.
type Future[T any] struct {
	mu        sync.Mutex
	st        state
	val       T
	err       error
	done      chan struct{}
	callbacks []func(T, error)
}

func NewFuture[T any]() *Future[T] {
	return &Future[T]{done: make(chan struct{})}
}

// Complete sets the result exactly once. Returns false if already
// completed/failed -- the double-complete guard (M2).
func (f *Future[T]) Complete(v T) bool {
	f.mu.Lock()
	if f.st != pending {
		f.mu.Unlock()
		return false
	}
	f.val = v
	f.st = completed
	cbs := f.callbacks
	f.callbacks = nil
	close(f.done) // publishes val/err to every Get(): close happens-before receive
	f.mu.Unlock()
	for _, cb := range cbs {
		cb(v, nil) // fire OUTSIDE the lock
	}
	return true
}

// CompleteError fails the Future exactly once.
func (f *Future[T]) CompleteError(err error) bool {
	f.mu.Lock()
	if f.st != pending {
		f.mu.Unlock()
		return false
	}
	f.err = err
	f.st = failed
	cbs := f.callbacks
	f.callbacks = nil
	close(f.done)
	f.mu.Unlock()
	var zero T
	for _, cb := range cbs {
		cb(zero, err)
	}
	return true
}

// Get blocks until the Future is done.
func (f *Future[T]) Get() (T, error) {
	<-f.done
	return f.val, f.err
}

// GetContext is Get with a deadline/cancellation (M4).
func (f *Future[T]) GetContext(ctx context.Context) (T, error) {
	select {
	case <-f.done:
		return f.val, f.err
	case <-ctx.Done():
		var zero T
		return zero, ctx.Err()
	}
}

// WhenComplete registers a callback. THE RACE this lab is about: the
// pending-check and the enqueue-or-fire decision happen under ONE lock, so
// a concurrent Complete can never land in the gap between them.
func (f *Future[T]) WhenComplete(cb func(T, error)) {
	f.mu.Lock()
	if f.st == pending {
		f.callbacks = append(f.callbacks, cb)
		f.mu.Unlock()
		return
	}
	v, e := f.val, f.err
	f.mu.Unlock()
	cb(v, e) // already done -- fire immediately, outside the lock
}

// ThenApply chains a transformation without blocking the caller.
func ThenApply[T, U any](f *Future[T], fn func(T) U) *Future[U] {
	next := NewFuture[U]()
	f.WhenComplete(func(v T, err error) {
		if err != nil {
			next.CompleteError(err)
			return
		}
		next.Complete(fn(v))
	})
	return next
}

Both compile clean under go build ./... and go vet ./..., and both were exercised end to end (including go test -race) before writing this page.

5. Break it — the test that fails

Change exactly one thing: move the state != PENDING check in whenComplete outside the lock, so it's a separate step from the enqueue. Everything else — complete(), the state machine, the callback list — is identical to the correct version above.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;

/** Same shape as MyFuture, except whenComplete checks "are we done?" OUTSIDE
 *  the lock. That reopens the register-after-complete race: complete() can
 *  run to completion in the gap between the stale read and the enqueue. */
public final class BuggyFuture<T> {
    private volatile boolean done = false;
    private T value;
    private final ReentrantLock lock = new ReentrantLock();
    private final List<BiConsumer<T, Throwable>> callbacks = new ArrayList<BiConsumer<T, Throwable>>();

    /** Test seam: forces the exact interleaving deterministically instead of
     *  hoping the OS scheduler cooperates. Set by the test, not used in prod. */
    public Runnable raceWindow;

    public boolean complete(T v) {
        List<BiConsumer<T, Throwable>> toRun;
        lock.lock();
        try {
            if (done) return false;
            value = v;
            done = true;
            toRun = new ArrayList<BiConsumer<T, Throwable>>(callbacks);
            callbacks.clear();
        } finally {
            lock.unlock();
        }
        for (BiConsumer<T, Throwable> cb : toRun) cb.accept(v, null);
        return true;
    }

    // Java -- BuggyFuture.whenComplete(), the ONLY structural change from MyFuture:
    void whenComplete(BiConsumer<T, Throwable> cb) {
        if (done) {                                 // BUG: read outside the lock
            cb.accept(value, null);
            return;
        }
        if (raceWindow != null) raceWindow.run();   // test seam: forces the gap open on demand
        // <-- complete() can run to completion in exactly this gap
        lock.lock();
        try {
            callbacks.add(cb);                       // too late: complete() already fired the (empty) list
        } finally { lock.unlock(); }
    }
}

The gap is one machine instruction wide under normal scheduling — the kind of race you could run a thousand times and never see. So the test doesn't hope for bad luck: it forces the exact interleaving with a synchronization checkpoint — a test seam (raceWindow/RaceWindow) that pauses the registering thread exactly between the stale read and the enqueue, hands control to the main thread to call complete(), then resumes. This is the standard way to make a Heisenbug reproduce deterministically, on every run, instead of chasing a flaky test:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;

public class FutureRaceTest {
    public static void main(String[] args) throws Exception {
        deterministicRaceRepro();
        stressTestFixedFutureNeverLoses();
        doubleCompleteIsIdempotent();
        thenApplyChains();
        System.out.println("ALL CHECKS PASSED");
    }

    /** Force the exact interleaving with a synchronization checkpoint --
     *  the standard way to make a Heisenbug deterministic in a test. */
    static void deterministicRaceRepro() throws Exception {
        final BuggyFuture<Integer> bf = new BuggyFuture<Integer>();
        final CountDownLatch checkPassed = new CountDownLatch(1);
        final CountDownLatch proceed = new CountDownLatch(1);
        bf.raceWindow = () -> {
            checkPassed.countDown();
            try { proceed.await(); } catch (InterruptedException ignored) {}
        };

        final boolean[] fired = { false };
        Thread registrar = new Thread(() -> bf.whenComplete((v, e) -> fired[0] = true));
        registrar.start();
        checkPassed.await();     // registrar has passed the stale "not done" check
        bf.complete(42);          // completes; runs the (currently empty) callback list
        proceed.countDown();     // release the registrar to append its now-useless callback
        registrar.join();

        System.out.println("BUG REPRODUCED: callback registered during complete() fired = " + fired[0]
                + " (expected false -- the callback is silently lost)");
        if (fired[0]) throw new AssertionError("expected the race to LOSE the callback, but it fired");
    }

    /** The fixed Future under the same concurrent pressure, many times over --
     *  proves the fix isn't a fluke of one interleaving. */
    static void stressTestFixedFutureNeverLoses() throws Exception {
        final int trials = 20000;
        int lost = 0;
        for (int i = 0; i < trials; i++) {
            final MyFuture<Integer> f = new MyFuture<Integer>();
            final boolean[] fired = { false };
            final CyclicBarrier start = new CyclicBarrier(2);
            final int val = i;
            Thread registrar = new Thread(() -> {
                await(start);
                f.whenComplete((v, e) -> fired[0] = true);
            });
            Thread completer = new Thread(() -> {
                await(start);
                f.complete(val);
            });
            registrar.start();
            completer.start();
            registrar.join();
            completer.join();
            if (!fired[0]) lost++;
        }
        System.out.println("stress test: lost " + lost + "/" + trials + " callbacks on the FIXED MyFuture (expected 0)");
        if (lost != 0) throw new AssertionError("fixed Future lost " + lost + " callbacks under concurrent stress");
    }

    static void await(CyclicBarrier b) {
        try { b.await(); } catch (Exception e) { throw new RuntimeException(e); }
    }

    /** Secondary break-it: without the `if (state != PENDING) return false` guard,
     *  a second complete() would silently overwrite the value. With the guard: */
    static void doubleCompleteIsIdempotent() throws Exception {
        MyFuture<Integer> f = new MyFuture<Integer>();
        boolean first = f.complete(1);
        boolean second = f.complete(999);
        int got = f.get();
        System.out.println("double-complete: first=" + first + " second=" + second + " value=" + got
                + " (expected true, false, 1)");
        if (!first || second || got != 1) throw new AssertionError("double-complete guard broken");
    }

    static void thenApplyChains() throws Exception {
        MyFuture<Integer> f = new MyFuture<Integer>();
        MyFuture<String> g = f.thenApply(x -> "answer=" + (x * 2));
        f.complete(21);
        String result = g.get();
        System.out.println("thenApply: " + result + " (expected answer=42)");
        if (!result.equals("answer=42")) throw new AssertionError("thenApply chain wrong: " + result);
    }
}

Run it:

BUG REPRODUCED: callback registered during complete() fired = false (expected false -- the callback is silently lost)
stress test: lost 0/20000 callbacks on the FIXED MyFuture (expected 0)
double-complete: first=true second=false value=1 (expected true, false, 1)
thenApply: answer=42 (expected answer=42)
ALL CHECKS PASSED

The Go version reproduces identically, deterministically:

// Go -- BuggyFuture has the same shape as Future, except WhenComplete checks
// "are we done?" OUTSIDE the lock. That reopens the register-after-complete
// race: Complete can run to completion in the gap between the stale read
// and the enqueue.
package futurelab

import (
    "sync"
    "sync/atomic"
    "testing"
)

type BuggyFuture[T any] struct {
    mu        sync.Mutex
    done      int32 // 0 = pending, 1 = completed -- read WITHOUT the lock, the bug
    val       T
    callbacks []func(T, error)

    // RaceWindow is a test seam: it forces the exact interleaving
    // deterministically instead of hoping the Go scheduler cooperates.
    RaceWindow func()
}

func (f *BuggyFuture[T]) Complete(v T) {
    f.mu.Lock()
    f.val = v
    atomic.StoreInt32(&f.done, 1)
    cbs := f.callbacks
    f.callbacks = nil
    f.mu.Unlock()
    for _, cb := range cbs {
        cb(v, nil)
    }
}

func (f *BuggyFuture[T]) WhenComplete(cb func(T, error)) {
    if atomic.LoadInt32(&f.done) == 1 { // BUG: read outside the lock
        cb(f.val, nil)
        return
    }
    if f.RaceWindow != nil {
        f.RaceWindow() // Complete() runs to completion HERE, in the test
    }
    f.mu.Lock()
    f.callbacks = append(f.callbacks, cb) // too late: Complete already fired the (empty) list
    f.mu.Unlock()
}

func TestRegisterAfterCompleteRaceIsLost(t *testing.T) {
    bf := &BuggyFuture[int]{}
    checkPassed := make(chan struct{})
    proceed := make(chan struct{})
    bf.RaceWindow = func() {
        close(checkPassed)
        <-proceed
    }

    fired := false
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        bf.WhenComplete(func(v int, err error) { fired = true })
    }()

    <-checkPassed   // registrar has passed the stale "not done" check
    bf.Complete(42) // completes; runs the (currently empty) callback list
    close(proceed)  // release the registrar to append its now-useless callback
    wg.Wait()

    if fired {
        t.Fatalf("expected the callback to be LOST (race reproduced), but it fired")
    }
}

Run it under go test -race -v:

=== RUN   TestRegisterAfterCompleteRaceIsLost
    future_test.go:37: BUG REPRODUCED: callback registered during Complete() never fired (fired=false)
--- PASS: TestRegisterAfterCompleteRaceIsLost (0.00s)
=== RUN   TestWhenCompleteNeverRaces
    future_test.go:71: stress test: lost 0/20000 callbacks on the fixed Future
--- PASS: TestWhenCompleteNeverRaces (0.17s)
=== RUN   TestDoubleCompleteIsIdempotent
--- PASS: TestDoubleCompleteIsIdempotent (0.00s)
=== RUN   TestThenApplyChains
--- PASS: TestThenApplyChains (0.00s)
PASS
ok  	futurelab	2.170s

Why -race doesn't catch this on its own: this is not a data race in the memory-model sense — every field access in the buggy version is still protected by some synchronization (the volatile/atomic read, the mutex around the list). Go's race detector and the JVM would both stay silent on this bug in a normal, unforced run. It's a logic race — a lost update at the level of "which of two orderings did the scheduler pick" — and the only way to catch it is either the forced-interleaving test above, or the stress test in the log: run the fixed MyFuture/Future through 20,000 concurrent register-vs-complete pairs with no forcing at all, and confirm zero lost callbacks, every time. The forced test proves the mechanism; the stress test proves the fix holds under real scheduling, not just the one interleaving you thought to force.

The fix is exactly Movement 3's rule restated: read the callbacks.add(cb) call back into the same lock acquisition as the state check (MyFuture.whenComplete / Future.WhenComplete above). With that fix, the forced-interleaving test can't even express the bug — the registering thread either holds the lock and finishes enqueuing before complete() can acquire it, or complete() finishes first and the registering thread's own state check (now inside the lock) correctly reports "already done."

6. Optimise — with trade-offs

ApproachMulti-consumer / callback semanticsComplexity you ownCancellation / timeoutUse when
Hand-rolled Future/Promise (this lab)Native — any number of get() callers and any number of whenComplete callbacks, registered before OR after completionYou own the state machine and the completion race — real surface area, as this lab just provedYou implement it (M4) — doable but manualLearning the mechanism, or a runtime with no async primitives at all
CompletableFuture (Java)Native, plus a huge combinator library (thenCompose, allOf, anyOf, orTimeout) — production-hardened against exactly the race in Movement 5None — the JDK maintainers own the state machineBuilt-in (orTimeout, completeOnTimeout)Default choice on the JVM for anything beyond a single submit().get() — don't hand-roll this in production, hand-roll it once here to understand it
Plain Future (Java, from ExecutorService.submit)Single-consumer in spirit — get() only, no callbacks, no chainingNone to own, but you're limited to blocking-pullget(timeout, unit) built inFire-and-collect: submit N tasks, later get() each result; you don't need reactive chaining
Unbuffered/buffered Go channelChannels are fundamentally either single-value-once (buffered cap 1, closed after) or a stream — not natively "N late-arriving callbacks fire immediately"; you'd hand-roll that on top, which is exactly what Future[T] above doesLow if the shape is genuinely "one value, one or few receivers" — the channel is the synchronizationselect + context.Context, idiomatic and cheapThe result has exactly one or a small, known set of consumers reading once; you don't need late-registration callback semantics
errgroup.Group (Go, golang.org/x/sync/errgroup)N/A — it's a fan-out/fan-in coordinator, not a value handle; use it to run M goroutines and collect the first error, not to hand a result to a late arriverVery low — a few lines around a WaitGroup and an errorerrgroup.WithContext cancels siblings on first error"Run these N things concurrently, stop everything if one fails, wait for the rest" — the classic parallel-fan-out shape, not a Future's late-callback shape

The real judgment call: reach for a bare channel when the consumer is known and reads once — it's the cheapest, hardest-to-misuse option because there's no separate "is it done yet" check to race. Reach for a Future/Promise (hand-rolled or CompletableFuture) the moment you need either multiple independent consumers, or a consumer that might register its interest after the work already finished, or a chain of transformations that shouldn't block the thread that sets them up. In Java, that almost always means "use CompletableFuture, don't hand-roll it" — you now know exactly what bug you'd be signing up to re-discover if you did. In Go, the idiomatic move is to reach for a channel first and only build a Future-shaped type when you catch yourself writing a second done bool guarded by a mutex next to it — that's the tell that you've actually got Future semantics and should name the type accordingly.

7. Defend under drilling

An interviewer will push on the design. These are the follow-ups that come up almost every time, with the answer a staff engineer gives — short, concrete, no hedging.

8. You can now defend


Re-authored/Deepened for this guide. Reference code compiled and executed before publishing: Java via javac -Xlint:all -Werror (clean) and java (all assertions pass, race reproduced on every run); Go via go build ./..., go vet ./..., and go test -race -v ./... (clean, 4/4 tests pass, race reproduced deterministically, 20,000-trial stress test on the fix shows zero lost callbacks). See also: Thread Pools, Executors & Futures.

🤖 Don't fully get this? Learn it with Claude

Stuck on Build a Future / Promise? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Build a Future / Promise** (Hands-On Builds) and want to truly understand it. Explain Build a Future / Promise from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Build a Future / Promise** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Build a Future / Promise** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Build a Future / Promise** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes