April 2026

# What Async Promised and What it Delivered

Each wave fixed the last wave's worst problem and introduced a new one.

OS threads carry overhead: an operating system thread reserves virtual address space for its stack and takes on the order of tens of microseconds to create on modern Linux. (edit Apr 27: The original “roughly a millisecond”, and “megabyte of stack space” conflated virtual reservation with physical memory commitment. Thanks to ibraheemdev, jemfinch, eklitzke on HN) Context switches happen in kernel space and burn CPU cycles, and O(n) readiness polling (select, poll) add up at scale. A server handling thousands of concurrent connections and dedicating one thread per connection means thousands of threads each consuming memory and competing for scheduling. The system spends time managing threads that could be better spent doing useful work.

This is the C10K problem, named by Dan Kegel in 1999. If you were building a web server, a chat system, or anything with a large number of simultaneous connections, you needed a way to handle concurrency without a thread per connection.

The answer came in waves, each solving the previous wave’s worst problem while introducing new ones. Previously we’ve looked at channels in Go and actors in Erlang. Now we turn to async, which is everywhere these days.

## Callbacks

The first wave was straightforward: don’t block the thread. Instead of waiting for an i/o operation to complete, register a function to be called when it finishes and move on to the next piece of work. Event loops (select, poll, epoll, kqueue) multiplexed thousands of connections onto a handful of threads, and callbacks were the programmer’s interface to this machinery.

Node.js built an entire ecosystem on this model, handling thousands of concurrent connections on a single thread. Nginx’s event-driven architecture was a major reason it displaced Apache for high-concurrency workloads.

This nicely solved the performance problem, but at a cost: callbacks invert control flow. Instead of writing “do A, then B, then C” as three sequential statements, you write “do A, and when it’s done call this function, which does B, and when that’s done call this other function, which does C.” The programmer’s intent becomes scattered across nested closures. JavaScript developers named this “callback hell” and built an entire website to commiserate.

Callbacks have deeper problems than aesthetics, such as fracturing error handling. Each callback needs its own error path. Errors can’t propagate naturally up the call stack because there is no call stack (callbacks run in a different context from where they are registered). Handling partial failure in a chain of callbacks means threading error state through every function in the chain.

Plus, callbacks have no notion of cancellation. If you start an asynchronous operation and then decide you don’t need the result, there’s no general way to stop it. The callback will fire eventually, and your code needs to handle the case where it no longer cares about the result.

Callbacks solved the resource problem (too many threads) by creating an ergonomics problem (code that’s hard to write, read, and get right).

## Promises and Futures

The next wave started with a good idea: what if, instead of passing a callback for later invocation, an asynchronous operation immediately returned an object representing its eventual result?

This is a promise (JavaScript) or future (Java, Rust, etc). The concept dates to Baker and Hewitt in 1977, but it took the C10K pressure of the 2010s to push it into mainstream programming. JavaScript standardized native Promises in ES2015 following the community-driven Promises/A+ spec, and Java 8 introduced `CompletableFuture`.

Promises are more ergonomic than callbacks. First, promises are composable: `promise.then(f).then(g)` reads as a pipeline instead of a nested pyramid. Error handling also consolidates: a `.catch()` at the end of a chain handles failures from any step. And promises are values that you can store, pass around, and return from functions. A first-class handle to an eventual value moves the conversation away from raw threads and toward data dependencies. The idea that “this value depends on a computation that hasn’t finished yet” is a useful thing to be able to express.

Here’s JavaScript reading a user profile and then fetching their recent orders, first with callbacks, then with promises:

    // Callbacks: nested, error handling at every level
    getUser(userId, (err, user) => {
        if (err) return handleError(err);
        getOrders(user.id, (err, orders) => {
            if (err) return handleError(err);
            render(user, orders);
        });
    });

    // Promises: chained, error handling consolidated
    getUser(userId)
        .then(user => getOrders(user.id).then(orders => [user, orders]))
        .then(([user, orders]) => render(user, orders))
        .catch(handleError);

The promise-based version is not a huge improvement on this small example, but the difference grows with complexity: five steps deep in callbacks is nearly unreadable, while five `.then()` calls chained together are at least linear.

But promises introduced their own problems:

Promises are one-shot. A promise resolves exactly once. This makes them unsuitable for modeling streams, events, repeated messages, or any ongoing communication. A WebSocket that receives a stream of messages doesn’t map onto “a value that will exist later.” This forces a split: promises for request-response patterns, and something else (event emitters, observables, callbacks again) for everything else.

Composition is clunky. The example above hints at it: getting both `user` and `orders` into the final `.then()` requires nesting or awkward gymnastics with `Promise.all`. Two independent async operations are easy (`Promise.all([a, b])`), but anything more complex (conditional branching, loops over async operations, early exit) requires increasingly elaborate combinator patterns. These patterns work but they’re a functional programming idiom grafted onto an imperative language and they don’t feel natural.

Errors vanish silently. JavaScript promises that reject without a `.catch()` handler originally just swallowed the error. The value was lost causing failures to be invisible. This was bad enough that Node.js eventually changed unhandled rejections from a warning to a process crash, and browsers added `unhandledrejection` events. A feature designed to improve error handling managed to create an entirely new class of silent failures that didn’t exist with callbacks.

The type split. Every function now returns either a value or a promise of a value. So callers need to know which one they’re getting and libraries need to decide which one to provide. A function that was synchronous becomes asynchronous when you add a database call to it, and now every caller needs to handle a promise instead of a value. This is a mild form of the coloring problem that the next wave would make even worse.

## Async/Await

Promise chains still looked nothing like the sequential code developers wrote for everything else. Async/await, pioneered by C# in 2012 and adopted by JavaScript (ES2017), Python (3.5), Rust (1.39), Kotlin, Swift, and Dart, delivered exactly that:

    // Promise chains
    function loadDashboard(userId) {
        return getUser(userId)
            .then(user => getOrders(user.id)
                .then(orders => [user, orders]))
            .then(([user, orders]) => render(user, orders));
    }

    // Async/await
    async function loadDashboard(userId) {
        const user = await getUser(userId);
        const orders = await getOrders(user.id);
        return render(user, orders);
    }

The async/await version reads like sequential code. Variables bind naturally. You can use `try/catch` instead of `.catch()`. Loops work with `await` inside them. It’s an ergonomic win for linear sequences of asynchronous operations.

The industry adopted it fast, with JavaScript frameworks going all-in, Python’s asyncio becoming the standard approach for concurrent i/o, and Rust stabilizing async/await as the path to high-performance networking. Within a few years, async/await was the default way to write concurrent i/o code in most mainstream languages.

## Paying the Function Coloring Tax

In 2015, right as async/await was gaining steam, Bob Nystrom published “What Color is Your Function?”, a thought experiment about a language where every function is either “red” or “blue.” Red functions can call blue functions, but blue functions can’t call red functions without special ceremony. Every function must choose a color, and if you call a red function from a blue one, the blue one must become red, spreading virally throughout the codebase.

This was an analogy to async/await: async functions are red, sync functions are blue. An async function can call a sync function without issue, but calling an async function from a sync function requires blocking the thread or restructuring the code. Every function in your program must choose a color, and that choice propagates through every caller.

Nystrom’s post stuck because it put a name to something developers had been experiencing without a vocabulary for it. Function coloring reshapes entire codebases and ecosystems.

The Rust async ecosystem fragmented around competing runtimes (Tokio, async-std, smol) that provide incompatible implementations of fundamental types like TCP streams and timers. A library written for Tokio can’t easily be used with async-std. The popular HTTP client `reqwest` simply requires Tokio, and if your project uses a different runtime, that’s your problem. Now library authors either pick Tokio (locking out alternatives) or attempt runtime-agnostic abstractions (adding complexity and sometimes performance overhead).

Tokio’s dominance is function coloring at ecosystem scale. The tax shows up at other scales too:

At the function level, adding a single i/o call to a previously synchronous function changes its signature, its return type, and its calling convention. Every caller must be updated, and their callers must be updated. The change ripples through the call graph until it hits a framework entry point or a main function. A one-line database lookup can require modifying dozens of files.

At the library level, authors face a choice of writing a sync library and exclude async users, or writing an async library and force sync users to add runtime dependencies (or maintain both). Many choose “both,” doubling the API surface, the test matrix, and the maintenance burden. In Python, the `requests` library (sync) and `aiohttp` (async) are separate projects by separate authors doing the same thing. `httpx` eventually appeared to offer both interfaces from one package, which is an improvement only needed because of the split.

At the ecosystem level, the Rust example above is the norm, not the exception. Every library that touches i/o must choose a color, and that choice limits which other libraries it can work with. The Rust async book itself notes that “sync and async code also tend to promote different design patterns, which can make it difficult to compose code intended for the different environments.”

And the costs aren’t just logistical: async/await introduced entirely new categories of bugs that threads don’t have. O’Connor documents a class of async Rust deadlocks he calls “futurelocks”: a future acquires a lock, then stops being polled while another future tries to acquire the same lock. With threads, a thread holding a lock always makes progress toward releasing it (unless you do something everyone knows is dangerous, like `SuspendThread`). With async Rust, the standard tools like `select!`, buffered streams, and `FuturesUnordered` routinely stop polling futures that hold resources. The original futurelock at Oxide required core dumps and a disassembler to diagnose.

## A Sequential Trap

A subtler cost that gets less attention is that async/await’s greatest strength, making asynchronous code look sequential, is also a cognitive trap.

    async function loadDashboard(userId) {
        const user = await getUser(userId);
        const orders = await getOrders(user.id);
        const recommendations = await getRecommendations(user.id);
        return render(user, orders, recommendations);
    }

This fetches orders and recommendations sequentially: `getRecommendations` doesn’t start until `getOrders` finishes. But these two operations are independent, because recommendations don’t depend on orders. So they could run in parallel, but don’t. The code looks clean and correct while leaving performance on the table.

The parallel version requires the programmer to explicitly break out of sequential style:

    async function loadDashboard(userId) {
        const user = await getUser(userId);
        const [orders, recommendations] = await Promise.all([
            getOrders(user.id),
            getRecommendations(user.id)
        ]);
        return render(user, orders, recommendations);
    }

The pattern scales poorly beyond small examples. In a real application with dozens of async calls, determining which operations are independent and can be parallelized requires the programmer to manually analyze dependencies and restructure the code accordingly. The sequential syntax actively obscures the dependency structure, i.e. the one piece of information that would tell you what can run in parallel.

Async/await was introduced to make asynchronous code easier to write. It made “what can run concurrently” something the programmer must determine manually and express through combinator patterns that break the sequential flow that was the whole point.

## What Async Got Right

To be fair, async abstractions did improve things.

Async/await’s ergonomics for linear sequences are better than callbacks or promise chains. For code that’s inherently sequential but happens to include i/o, async/await removes real syntactic noise. It’s easier to read and debug than callback-based code.

Some language designers chose different paths. For example, Go deliberately chose goroutines, accepting a heavier runtime in exchange for no function coloring at all. (Edit note Apr 24: Go actually introduced a form of coloring through `context.Context`, which propagates through calls for cancellation. Edit Apr 27: previous language implied Go made the decision in reaction to async/await) Java’s Project Loom (virtual threads in Java 21) also made a different bet: lightweight threads that look and behave like regular threads, so no code needs to change color. The Loom team explicitly cited function coloring as a problem they wanted to avoid.

Zig went further: it removed its compiler-level async/await entirely and rebuilt around an Io interface parameter that i/o operations accept. The runtime (threaded, event-loop, whatever the user supplies) fulfills the interface. Function signatures don’t change based on how they’re scheduled, and async/await become library functions rather than language keywords. Though some argue that the Io parameter itself is a form of coloring.

Language designers who studied the async/await experience in other ecosystems concluded that the costs of function coloring outweigh the benefits and chose different paths.

## Accumulating Costs

Each solution solved a problem but introduced new costs. And those costs are structural, affecting the shape of every program, library, and API in the codebase.

Wave | Solved | Introduced
--- | --- | ---
Callbacks | Thread-per-connection resource exhaustion | Inverted control flow, fragmented error handling, callback hell
Promises | Nesting, error consolidation, values over callbacks | One-shot limitation, silent error swallowing, mild type split
Async/Await | Ergonomics for linear async sequences | Function coloring, ecosystem fragmentation, new deadlock classes, sequential trap

Each wave made the local experience of writing async code more pleasant while making the global experience more complex. The developer writing a single async function has never had it better, while the team maintaining a large codebase with mixed sync/async code, managing dependency compatibility across runtimes, and trying to find parallelism opportunities hidden behind sequential-looking `await` chains are carrying a burden that didn’t exist before these abstractions were introduced.

This isn’t a case of bad engineering. The people who designed callbacks, promises, and async/await were solving real problems, and each step was a reasonable response to the previous step’s failures. But fifteen years and several iterations in, the accumulated tax is sizable, and a pattern is visible: each fix treats symptoms while leaving the structure intact.

The callbacks-to-promises-to-async/await arc may be the clearest illustration yet of a theme running through this series: approaches that start by asking “how do we manage concurrent execution?” keep generating new problems at every level of abstraction. You can watch this one play out in real time, across a single ecosystem, within a single decade.

### References

- Baker, Henry and Carl Hewitt. “The Incremental Garbage Collection of Processes.” ACM SIGART Bulletin 64 (1977): 55–59.
- Kegel, Dan. “The C10K Problem.” 1999.
- Nystrom, Bob. “What Color is Your Function?” February 1, 2015.
- Elizarov, Roman. “How Do You Color Your Functions?” Medium, November 18, 2019.
- Cro, Loris. “Zig’s New Async I/O.” Blog post, 2025.
- “Virtual Threads in Java.” Oracle Java Magazine.
- Corrode Rust Consulting. “The State of Async Rust: Runtimes.” Blog post.
- O’Connor, Jack. “Never Snooze a Future.” Blog post, 2026.

Discuss:Hacker News Lobsters

### Stay in the loop

Get notified when new posts are published. No spam, no tracking, just ideas.