Photo by Florian Olivo on Unsplash
Coroutine Coloring: Why Async Functions Infect Your Codebase
The moment you add async to a function signature, you create a boundary that ripples outward through your entire call stack. This phenomenon—often called “function coloring”—is one of the most debated design consequences of async/await concurrency models.
The Two-World Problem
In languages with explicit async syntax like JavaScript, Python, Rust, and C#, functions split into two incompatible categories: synchronous functions that execute immediately and return values, and asynchronous functions that return promises or futures. The critical constraint is that async functions cannot be called directly from sync contexts without blocking or breaking the concurrency model.
This creates a one-way membrane. Sync functions can be called from async contexts trivially—just await their result. But the reverse requires either blocking the entire thread (defeating the purpose of async) or restructuring the caller to be async as well. Once a function deep in your call stack needs to perform an async operation like a network request or file I/O, every function above it in the stack must become async to propagate the result back up.
Why Coloring Exists
The division stems from how async runtimes schedule work. Async functions yield control at await points, allowing the runtime to multiplex thousands of concurrent tasks onto a small number of OS threads. Synchronous functions never yield—they run to completion or block the thread entirely. Mixing them naively would either waste threads (by blocking them during I/O) or introduce hidden blocking that destroys the runtime’s ability to schedule efficiently.
Languages chose explicit coloring because it makes the execution model visible in the type system. When you see an async function signature, you know it may yield, that it requires a runtime to execute, and that its return value must be awaited. This visibility helps prevent accidental blocking and makes concurrency boundaries explicit in large codebases.
The Infection Pattern
Function coloring spreads outward from I/O boundaries. A database query, HTTP request, or file read forces the immediate caller to be async. That caller’s caller must also become async to await the result. The coloring propagates upward until it hits a boundary where the runtime is initialized—typically main() or a request handler.
This creates architectural pressure. Libraries that expose async APIs force all their consumers to adopt async. Codebases become bifurcated: an async “zone” handling I/O and concurrency, and a sync “zone” for pure computation. Crossing between them requires careful orchestration. Some ecosystems develop dual APIs—sync and async versions of the same library—which doubles maintenance burden and fragments the ecosystem.
Alternatives and Tradeoffs
Go avoided function coloring entirely by making all blocking operations yield implicitly. Goroutines use a runtime scheduler that context-switches on blocking calls, allowing synchronous-looking code to execute concurrently without explicit async syntax. The tradeoff is less control: you cannot tell from a function signature whether it might yield, and accidental blocking is harder to detect at compile time.
Structured concurrency models in Swift, Kotlin, and newer Python features reduce infection by scoping async contexts more precisely. Virtual threads in Java 21 eliminate coloring by making every thread cheap enough that blocking becomes acceptable. WebAssembly’s stack-switching proposal aims to decouple async operations from function signatures entirely, allowing libraries to expose sync interfaces backed by async implementations.
Living With the Color
For ecosystems already committed to async/await, the pragmatic response is to embrace the division. Async contexts become the default for I/O-heavy code paths, with sync functions reserved for leaf computations that never need concurrency. Codebases develop conventions: request handlers are async, business logic functions are async, but pure data transformations and algorithms remain sync.
The alternative—attempting to keep most code sync and minimizing async spread—often fails because real applications perform I/O at many levels. Authentication, logging, caching, and metrics collection all involve async operations. Fighting the color boundary creates awkward adapter layers and hidden blocking that undermines the concurrency model.
Function coloring is not a bug. It is a design choice that trades implicit complexity for explicit boundaries, making concurrency visible at the cost of viral syntax. Whether that tradeoff feels like an infection or a feature depends largely on whether your codebase has committed to async as the default or treats it as an exception.