Intermediate14 min readConcurrency & Thread Safetylive prototype

Futures, Promises & async

A Future is a receipt for a value that doesn't exist yet. You ask for something slow, and instead of standing there waiting, you get a placeholder object immediately — the real value drops into it later. Think of the buzzer a coffee shop hands you: you order, take the buzzer, go sit down and do other things, and when it flashes your drink is ready. Async programming is just that idea applied to network calls, disk reads and database queries.

The idea

What it is

Some things are slow: a network call, a database query, reading a file. The naive way to write them is stop and wait — your thread sits there, doing absolutely nothing, until the answer comes back. A Future (also called a promise, a task, or a deferred) is the fix. Instead of the value, the slow call hands you back a small object immediately: an empty box with a label saying "the answer will be in here later". You can carry that box around, hand it to other code, or say what should happen once it fills up — all without your thread standing still.

The everyday version is the coffee shop buzzer. You order a latte. The barista doesn't make you stand at the counter staring at the machine — they hand you a buzzer and take the next order. The buzzer isn't your coffee; it's a claim on a coffee that will exist in three minutes. You go sit down, open your laptop, answer an email. When the buzzer flashes, you walk up and collect. The buzzer is the Future. Sitting down and doing other work is async. And standing at the counter watching the barista? That's calling get() the instant you get the buzzer — technically legal, completely pointless.

The one sentence to remember

A Future is a placeholder handed to you now for a value that arrives later. It starts PENDING and settles exactly once — either COMPLETED with a value or FAILED with an error — and it never changes again. The whole benefit comes from what you do between getting the Future and reading it.

You've already used one

Every JavaScript fetch() returns a Promise — the same idea, different name. await fetch(url) is you saying "pause this function until the buzzer flashes, but let the thread go do other things meanwhile." Java calls it CompletableFuture, Python calls it an awaitable / asyncio.Task, C# calls it Task<T>, and Go doesn't name it at all — a goroutine writing to a channel is the future.

Mechanics

How it works

Three states, settled once

A Future is a tiny state machine with exactly three states, and it moves through them once:

  • PENDING — the default. The work is in flight; the box is empty. Anyone who asks for the value now has to wait.
  • COMPLETED — the work finished and the value is in the box. Reading it is instant from now on, forever, for everyone.
  • FAILED — the work threw. The box holds an error instead of a value, and reading it re-throws that error at you.

The word for "reached COMPLETED or FAILED" is settled. Settling is a one-way door: a Future never goes back to pending, and never settles twice. That immutability is what makes Futures safe to share — ten different pieces of code can hold the same Future and they all see the same single result.

Future vs Promise: the read side and the write side

These two words get used interchangeably, but classically they name the two ends of the same box. The Future is the read side — what the caller holds, the thing you wait on or attach callbacks to; it has no way to set a value. The Promise is the write side — what the producer holds, the thing with complete(value) and completeExceptionally(error) on it. Whoever is doing the work fulfils the promise; whoever is waiting reads the future.

Languages split this differently. C++ is explicit: std::promise (producer) hands out a std::future (consumer). Java's CompletableFuture merges both roles into one class, which is why it can both be awaited and be completed by hand. JavaScript's Promise is confusingly named — it's actually the read side; the write side is the resolve/reject pair passed into the constructor. Same mechanism every time: one end writes once, the other end reads.

The whole point is overlap

Here's the mistake everybody makes first. You submit three tasks, and then immediately call get() on each one inside the loop:

java
// ❌ The classic beginner mistake — serial code with extra steps
for (String id : ids) {
    Future<Price> f = executor.submit(() -> fetchPrice(id)); // starts...
    Price p = f.get();          // ...and we immediately stand there waiting
    total += p.amount();        // 3 × 300 ms = 900 ms
}

// ✅ Submit everything FIRST, then collect — the waits overlap
List<Future<Price>> futures = new ArrayList<>();
for (String id : ids) {
    futures.add(executor.submit(() -> fetchPrice(id))); // all in flight now
}
for (Future<Price> f : futures) {
    total += f.get(2, TimeUnit.SECONDS).amount();       // ≈ 300 ms in total
}

Both versions use Futures. Only the second one is faster. get() blocks — it parks the calling thread until the Future settles — so calling it right after you submit hands back all the benefit you just bought. The gain isn't the Future object; it's the overlap you create in the gap between submitting and reading. Three 300 ms calls that wait at the same time finish in roughly 300 ms, because you're waiting on the network, not on the CPU, and waiting is something you can do in parallel for free.

The gotcha: submit-then-get() in a loop

If your code reads submit(...) immediately followed by .get() inside the same loop iteration, you have written synchronous code with a thread pool attached — all the complexity, none of the speed. Fire every independent call first, keep the Futures in a list, and only then start collecting. In the prototype this is the entire ⛔ Blocking vs ⚡ Async contrast: 900 ms versus 300 ms for identical work.

Callbacks and chaining: say what happens next

Blocking on get() is the crude way to read a Future. The good way is to attach a continuation: tell the Future what to run once it settles, and walk away without holding a thread hostage. thenApply (Java), .then (JavaScript) and await (Python, C#, JS) all do this. Chaining then gives you two composition moves:

  • Transform / sequencethenApply(fn) maps the value; thenCompose(fn) runs another Future afterwards (JS: returning a promise from .then). This is genuinely dependent work — call B needs call A's result — so it can't overlap, and the total time is the sum. That's the 🔗 then chain chip in the prototype.
  • Combine / fan-inCompletableFuture.allOf(a, b, c) (Java), Promise.all([a, b, c]) (JS), asyncio.gather(...) (Python), a sync.WaitGroup or a channel drain (Go). These are independent calls, so they overlap and the total is the slowest one, not the sum.

Errors travel down the chain

When a Future fails, the error doesn't get thrown where you attached the callback — that code already returned long ago. Instead the failure propagates down the chain: every thenApply after it is skipped, and the error keeps sliding until something catches it (exceptionally / handle in Java, .catch in JS, try/except around await in Python). If nothing catches it, the error is stored inside the last Future and silently disappears — no stack trace, no crash, just a value that never shows up. Node calls this an unhandled rejection; Java calls it a Future nobody joined. Both are nasty to debug.

A Future you never read can swallow an exception whole

Fire-and-forget async work is where bugs go to hide. If you don't join(), await, or attach a .catch(), a failure inside that task may never surface anywhere. Always terminate a chain with an error handler — even if that handler only logs.

Timeouts and cancellation

Never wait forever on something that talks to a network. Every Future API gives you a bounded wait — f.get(2, TimeUnit.SECONDS), asyncio.wait_for(coro, timeout=2), future.wait_for(2s) in C++, select with context.WithTimeout in Go. And when you no longer need a result — the user closed the page, a sibling call already failed — cancel it: future.cancel(true), task.cancel(), AbortController, a cancelled context.Context. Cancellation is cooperative almost everywhere: it sets a flag or fires an interrupt, and the task has to notice and stop. A timeout without a cancel just means you stopped waiting while the work keeps burning resources.

Callback hell vs async/await

Early callback-based code nested every step inside the previous step's handler, and three or four levels in it became an unreadable staircase — callback hell. async/await fixes the reading problem: var price = await fetchPrice(); looks like a plain blocking line, but the compiler chops the function in half at that await, returns control to the runtime, and resumes the rest when the Future settles. It's the same Futures and callbacks underneath; you just get to write it top to bottom, with normal try/catch and normal loops.

async ≠ more threads

This is the single most misunderstood part. Async isn't about adding threads — it's about not blocking a thread while waiting. A blocked thread costs memory (a whole stack) and does zero work. An event loop on one thread can hold thousands of pending I/O operations at once, because each one is just a small entry in a table saying 'when this socket is ready, resume here.' That's how Node.js serves thousands of concurrent connections on one thread. Async buys you concurrency (many things in flight); threads buy you parallelism (many things computing at once). For CPU-bound work you still need threads.

How the four languages spell it

  • JavaFuture<T> is the old, blocking-only interface (get(), cancel()); CompletableFuture<T> adds thenApply / thenCompose / allOf / exceptionally. Since Java 21, virtual threads offer an alternative: write plain blocking code and let the JVM park the cheap virtual thread instead of an OS thread.
  • Pythonasyncio with async def, await, asyncio.gather(...) and asyncio.wait_for(...), all on a single-threaded event loop; concurrent.futures gives thread-pool Future objects for the blocking style.
  • C++std::future / std::promise / std::async, plus wait_for for timeouts. get() moves the value out and re-throws any stored exception; C++20 coroutines add co_await on top.
  • Go — has no Future type on purpose. You launch a goroutine and give it a channel; the channel is the future, and receiving from it is the get(). sync.WaitGroup is your allOf, and context.Context carries the timeout and cancellation.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A timeline of three 300 ms callsfetchPrice, fetchStock, fetchReviews — with a 🧵 Main thread lane above them. Start in ⛔ Blocking get() and press ▶ Run: the three bars draw end to end (0 → 300 → 600 → 900) while the main-thread lane is one long amber BLOCKED in get() bar, and the readouts land on total: 900 ms and main thread blocked: 900 ms in red. Now flip to ⚡ Async .then() and press ▶ Run again: the same three bars fire stacked in parallel, the main-thread lane stays green with little other-work ticks, and the readouts read total: 300 ms and main thread blocked: 0 ms. Watch the 🧾 Future&lt;Price&gt; card: it appears at t = 0 as PENDING ⋯ — the receipt — and only flips to COMPLETED ✓ $42.10 at t = 300. Turn on ⚠️ Fail to make fetchPrice time out: its bar turns red, the card reads FAILED ✗ timeout, .then(show) is skipped and .catch(err) lights up. 🔗 then chain makes fetchStock wait for fetchPrice, so the total stretches back out — dependent work can't overlap.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Watch get() waste 900 ms

Leave the toggle on ⛔ Blocking get() and press ▶ Run. The three ☕ bars draw one after another — 0–300, 300–600, 600–900 — while the 🧵 Main thread lane fills with a single amber ⏸ BLOCKED in get() bar for the entire time. The readouts settle on total: 900 ms and main thread blocked: 900 ms in red. That's submit-then-get() in a loop: three Futures, zero overlap.

try 02

Flip to async and watch them stack

Click ⚡ Async .then() and press ▶ Run again. The same three bars now start together at t = 0 and finish at 300 — total: 300 ms, main thread blocked: 0 ms in green, and the main lane shows ✔ other-work ticks instead of a blocked bar. Now watch the 🧾 Future<Price> card: it appears at t = 0 reading PENDING ⋯ (that's your buzzer) and only flips to COMPLETED ✓ $42.10 at t = 300, when .then(show) lights up. Receipt now, value later.

try 03

Break it, then chain it

Turn on ⚠️ Fail and run: fetchPrice's bar goes red, the card reads FAILED ✗ timeout, .then(show) is skipped and .catch(err) lights up — the error slid down the chain to the only thing that could handle it. Now switch ⚠️ Fail off, turn on 🔗 then chain and run: fetchStock no longer starts at 0, it waits for fetchPrice to settle and the total stretches to 600 ms. Dependent work can't overlap. ↺ Reset puts everything back.

In practice

When to use it — and what trips people up

Reach for Futures / async when...

  • The work is I/O-bound — network calls, database queries, file reads, other services. The thread is waiting, not computing, and waiting is exactly what you can overlap for free.
  • You have several independent slow calls — a page that needs price and stock and reviews. Fire them all, then combine with allOf / Promise.all / gather. Total time becomes the slowest call, not the sum.
  • You need to stay responsive — a UI that must not freeze, or a server that must keep accepting connections while requests are in flight.
  • Concurrency is high but each unit is cheap — thousands of open sockets. One event loop holds them all; thousands of blocked threads would not fit.

Skip it when...

  • The work is CPU-bound — hashing, image resizing, big matrix maths. Async doesn't make the CPU faster; you need real threads or processes for that.
  • The calls genuinely depend on each other — if B needs A's result, chaining buys you nothing over straight-line code except complexity. Only independent work overlaps.
  • There's just one quick call and nothing else to do — a plain synchronous call is simpler, easier to debug, and has a readable stack trace.
  • Your runtime already gives you cheap blocking — with Java 21 virtual threads or Go goroutines you can often write plain blocking code and let the runtime park it, keeping the readability without the callback machinery.

What it gives you

  • Overlapped waiting — several independent slow calls finish in the time of the slowest one instead of the sum.
  • Threads stay free — no thread is parked doing nothing, so one thread can juggle thousands of pending I/O operations.
  • Composable — chain with thenApply/then/await for dependent steps, combine with allOf/Promise.all/gather for independent ones.
  • Explicit failure and timeouts — a settled Future carries its error, and every API offers a bounded wait plus cancellation.

Common mistakes

  • Easy to write fake-async code — submit followed immediately by get() is serial execution with extra ceremony and no speedup.
  • Errors vanish silently — a Future nobody awaits or catches can swallow an exception entirely, with no stack trace to find it by.
  • Harder debugging — stack traces break at every async hop, so 'who called this?' is no longer answerable by reading the stack.
  • Contagious style — async functions tend to force their callers to be async too, splitting a codebase into blocking and non-blocking halves.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.concurrent.*;
import java.util.List;

public class PriceService {

    // Each of these takes ~300 ms of pure waiting (network).
    static String fetchPrice()   { sleep(300); return "$42.10"; }
    static String fetchStock()   { sleep(300); return "12 left"; }
    static String fetchReviews() { sleep(300); return "4.6 stars"; }

    public static void main(String[] args) throws Exception {

        // ❌ BLOCKING: get() right after submit = serial. ~900 ms.
        ExecutorService pool = Executors.newFixedThreadPool(4);
        long t0 = System.currentTimeMillis();
        String p1 = pool.submit(PriceService::fetchPrice).get();   // blocks here
        String s1 = pool.submit(PriceService::fetchStock).get();   // ...and here
        String r1 = pool.submit(PriceService::fetchReviews).get(); // ...and here
        System.out.println("blocking: " + (System.currentTimeMillis() - t0) + " ms");

        // ✅ ASYNC: fire everything first, THEN combine. ~300 ms.
        long t1 = System.currentTimeMillis();
        CompletableFuture<String> price   = CompletableFuture.supplyAsync(PriceService::fetchPrice, pool);
        CompletableFuture<String> stock   = CompletableFuture.supplyAsync(PriceService::fetchStock, pool);
        CompletableFuture<String> reviews = CompletableFuture.supplyAsync(PriceService::fetchReviews, pool);
        // 'price' is the RECEIPT — it exists now, the value lands later.

        CompletableFuture<String> card = price
            .thenApply(v -> "Price: " + v)          // runs AFTER it settles, blocks nobody
            .exceptionally(err -> "Price unavailable");  // errors slide down to here

        CompletableFuture.allOf(price, stock, reviews).join();   // fan-in: waits for the SLOWEST
        System.out.println("async: " + (System.currentTimeMillis() - t1) + " ms");
        System.out.println(card.get(2, TimeUnit.SECONDS));       // ALWAYS bound the wait

        // Dependent work can't overlap — thenCompose sequences it.
        CompletableFuture<String> shipping = price.thenCompose(v ->
            CompletableFuture.supplyAsync(() -> "ship for " + v, pool));
        System.out.println(shipping.join());

        // No longer need a result? Cancel it (cooperative: interrupts the worker).
        CompletableFuture<String> slow = CompletableFuture.supplyAsync(PriceService::fetchReviews, pool);
        slow.cancel(true);
        System.out.println("cancelled: " + slow.isCancelled());

        pool.shutdown();
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 06

What does a call like executor.submit(task) hand back to you immediately?

question 02 / 06

A colleague writes: for (id : ids) { var f = pool.submit(() -> fetch(id)); results.add(f.get()); }. Three fetches take 300 ms each. How long does the loop take, and why?

question 03 / 06

Classically, what is the difference between a Future and a Promise?

question 04 / 06

An async task throws, and nothing in the chain calls .catch() / exceptionally() / awaits the Future. What happens?

question 05 / 06

Which statement about async I/O is correct?

question 06 / 06

You need call B's result to make call C, but call A is independent of both. What's the right composition?

0/6 answered