Skip to content
← All articles

The function-colouring debate, honestly

Async functions can only be called from async contexts, which splits ecosystems and duplicates APIs. That critique is correct — and so are the counterarguments. The track ends with calibration rather than a verdict.

You have just built an async runtime from scratch. This is the wrong moment for a victory lap and the right moment for calibration, because the most useful thing an experienced engineer knows about async is when not to use it.

That judgement rests on a real, unresolved argument. It deserves to be presented as one.

The critique

The canonical statement is Bob Nystrom’s 2015 essay What Color Is Your Function?. The argument, stripped to its bones:

In a language with async functions, every function has a colour. Red functions are async; blue functions are not. And the rules are asymmetric:

  • a blue function can be called from anywhere;
  • a red function can only be called from a red function;
  • so redness is infectious upward through your entire call graph.

The consequences are not hypothetical, and you can see all of them in the Rust ecosystem today:

Duplicated APIs. reqwest has a blocking module. sqlx and postgres have sync and async siblings. std::fs::read and tokio::fs::read do the same job with different colours. Every one of those pairs is a maintenance cost paid forever.

Traits split down the middle. Iterator and Stream are the same concept in two colours. Read/Write and AsyncRead/AsyncWrite. Every combinator, every adaptor, written twice.

Awkward boundaries. Drop cannot be async — destructors run synchronously, so an async cleanup has to be done explicitly before the value dies, or spawned and hoped for. Trait methods in dyn position needed a workaround for years (async-trait‘s boxing) before native async fn in traits arrived, and dyn compatibility is still not free.

block_on is a trap. Calling a blocking block_on from inside an async context deadlocks or panics on most runtimes: you are asking the worker thread that is currently running your task to sit and wait for another task that can only run on that same worker. This follows directly from colouring — someone needed to call red code from blue code, and reached for the bridge.

That is a serious indictment and none of it is wrong.

The counterarguments

They are also not wrong.

Colouring is the price of no runtime. Rust’s async functions compile to inert state machines with no scheduler attached. That is what lets async Rust run on a microcontroller with no allocator, inside a kernel, in a WASM module, and on a 128-core server, using the same syntax and the same trait. Erasing the colour distinction means the language has to know how to suspend arbitrary code, which means the language needs a built-in runtime, which means every Rust program pays for one.

Rust already tried the alternative, and removed it. Green threads and a segmented-stack scheduler were in the language before 1.0. They were taken out in 2014 (RFC 230) precisely because they imposed a runtime and a stack model on every program, including the ones that wanted to be a shared library, an embedded binary, or a plugin inside a C host. Go can have uncoloured functions because Go always ships a runtime. Rust decided it could not.

Colouring is information, not just friction. async fn in a signature tells you this function may suspend, may be cancelled at any of its await points, and must not be called from a context that cannot wait. In a language with implicit suspension, any call might yield and you cannot tell by looking. Rust makes the same visible commitment with unsafe, mut, and Result; some people find that verbose, and some find it exactly the point.

Cancellation and zero-cost composition fall out of the same design. Because the future is a value you own, dropping it cancels it — synchronously, with destructors, with no allocation and no cooperative token protocol. join and select are ordinary combinators over ordinary values. Those properties are consequences of the state machine being explicit, which is what colouring buys.

Do not resolve this by fiat

Both sides are describing the same mechanism from different distances. If you mostly write application code that lives entirely inside one runtime, the colouring tax is a real and daily annoyance. If you write libraries that must run in environments with no allocator, or care about the cost of every suspension, the explicit state machine is precisely why you chose Rust.

The honest position is that Rust made a tradeoff that has genuine costs, for reasons that are also genuine, and that reasonable people weigh those differently.

The decision heuristic you actually want

Here is the practical version, and it is shorter than the debate:

  • CPU-bound work → threads. Async gives you nothing and costs you a scheduler. std::thread, or rayon if it parallelises.
  • Low concurrency I/O → threads. Dozens of connections, a CLI tool, a batch job, a build script. A blocking call on a thread is faster to write, faster to debug, and often faster to run.
  • High concurrency I/O → async. Thousands of mostly-idle waits. This is the case async exists for, and it is dramatic when it applies.
  • Everything else → neither. Most programs are not concurrent at all, and adding a runtime to one is a cost with no benefit.

Notice that async wins one row of four. That is not a criticism of async; it is a statement about how many programs need it.

💡Your team is writing a CLI that fetches from three APIs, merges the results, and prints a table. Someone proposes making it async so the three fetches run concurrently. Is that the right call? click to reveal

It is a defensible call and probably not the best one, and the interesting part is why.

The concurrency win is real: three sequential 200 ms round trips become one 200 ms wait. But the cheapest way to get it is three threads and three blocking calls, joined at the end. Three threads is nothing. You get the same latency, you keep synchronous error handling and backtraces, you avoid a runtime dependency and a #[tokio::main], and every function in the program stays one colour.

std::thread::scope makes this genuinely small — spawn three scoped threads that borrow local data, join, merge. No Arc, no 'static bound, no runtime.

Reach for async when the answer changes shape: three hundred APIs instead of three; a long-lived process holding open connections; a server where each request needs its own concurrency; or an ecosystem constraint where the only good client crate is async-only. Any of those flips it, and the last one flips it most often in practice.

The failure mode to avoid is adopting async for the syntax — because join! reads nicer than spawning threads — and then discovering you have inherited Send + 'static bounds, cancel-safety questions, a runtime dependency and a second colour for the whole program, in exchange for something thread::scope would have done in five lines.

What you actually learned

Look back at what is now in your file, from std only:

a hand-written Future; a block_on; a real Waker via Wake, and the RawWakerVTable underneath it; a hand-compiled state machine; join and select; a pin projection with its four obligations discharged; a single-threaded executor with per-task wakers; spawn and JoinHandle with cross-task wakeup; and a reactor with a logical clock and a timer heap.

That is a runtime. Not a metaphor for one.

Which means the useful skill you leave with is not “I can use tokio”. It is that when a task hangs at 0% CPU you know to look at what the last Pending did with cx.waker(); when a future will not go into tokio::spawn you know to look for a guard held across an await; when select! starts losing data you know the loser was dropped mid-flight; and when someone proposes rewriting a batch job in async you know to ask what is waiting.

And knowing when the answer is “just use a thread” is not a rejection of any of it. It is the calibration that makes the rest worth having.

Async From First Principles · step 25 of 25

That's the end of this track. Review it or pick another.

← Back to Async From First Principles