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. + +