Most criticism of C++20 coroutines lands on the boilerplate. The promise_type you write from scratch for every new coroutine return type, the absence of a standard Task<T> in the initial release, the template error messages that fail to name the required method you forgot to implement. These are legitimate complaints. But Andrzej Krzemieński’s December 2025 post on isocpp.org goes after something harder: two criticisms that do not go away once you adopt a good coroutine library or wait for C++23 to ship std::generator. They are structural properties of how C++ coroutines were designed, and being honest about them matters more than defending the feature.
The Dangling Reference Problem
When a C++ coroutine function takes a parameter by reference, that reference is copied into the heap-allocated coroutine frame. The frame outlives the call site. If the coroutine suspends and the object that was passed in by reference goes out of scope in the caller, the frame holds a dangling reference.
Task<void> process(const std::string& config) {
co_await async_setup(); // suspends here
apply(config); // config may be dangling by now
}
// At the call site:
auto task = process(load_config()); // temporary destroyed at semicolon
// task is now a coroutine with a dangling reference inside
The compiler does not warn. The lifetime violation is silent until it produces a crash or memory corruption at runtime, often far from where the coroutine was created. This is not a theoretical edge case; it is the natural pattern for fire-and-forget coroutines, where the caller creates the coroutine and moves on.
The fix is well known: take everything used across a suspension point by value, or explicitly move it into the coroutine body before the first co_await. The problem is that this requirement is invisible at the interface level. A coroutine declared as Task<void> process(const std::string& config) looks like a function that takes a string reference, which in C++ conventionally means “I borrow this for the duration of the call.” The coroutine breaks that convention silently. The caller has no way to know without reading the implementation.
Rust’s async fn handles this differently. When an async function captures a reference, the compiler enforces that the reference’s lifetime covers the entire future. If you try to create a future that outlives the borrowed data, the borrow checker rejects it at compile time. The ergonomics are harder when you want to store a future containing a borrow, but the unsafe pattern does not reach production silently. Go sidesteps the issue through a different mechanism: goroutines communicate through channels and share heap-allocated data explicitly, and the race detector catches unsynchronized access during testing.
C++ has neither solution available without a language change. Catching the dangling reference at compile time requires lifetime analysis that does not exist in the C++ type system. Adding it would be a non-trivial language extension, not a library patch. The current situation is documented, understood by C++ experts, and has no automatic remedy for developers who do not know to look for it.
The Indistinguishability Problem
The second criticism is subtler but follows a similar logic. In Python, any function marked async def is visibly a coroutine at its declaration. In Rust, async fn serves the same purpose. In Kotlin, suspend fun marks the coroutine at the declaration site. In each of these languages, you can read a function signature and know immediately whether calling it crosses an asynchronous boundary.
In C++, the marker is the return type:
// Regular function:
std::string compute(int n);
// Coroutine:
Task<std::string> compute(int n);
The distinction exists, but it requires knowing that Task<std::string> is a coroutine return type. The declaration has no keyword, no syntactic annotation, no language-level marker. Whether this matters depends on whether you consider the return type a sufficient signal.
Krzemieński’s position, reasonable on its own terms, is that the type system is an adequate communication channel for this information. C++ has always encoded invariants in types rather than keywords when flexibility demands it, and the approach allows multiple coroutine return types with different scheduling semantics to coexist in one codebase without conflict. You cannot do that in Python, where async def ties you to a single coroutine protocol.
The practical cost shows up in two places. First, readability for developers unfamiliar with the codebase: tracing every function’s return type to determine whether a call is synchronous or asynchronous is work that a keyword would eliminate. Second, tool support: co_await in the body is grep-friendly, but the coloring of a function as a coroutine is not a keyword-searchable annotation on the declaration itself. Static analysis tools can detect it, but casual code review cannot.
There is also a subtler failure mode. Because there is no keyword requirement, a coroutine can be called and its return value discarded, and the coroutine body never executes:
compute(42); // no error, no warning; the coroutine never ran
The [[nodiscard]] attribute on the return type catches this with a compiler warning, and good coroutine libraries apply it consistently. But this is a convention enforced by library discipline, not a language guarantee. A bespoke coroutine type without [[nodiscard]] silently drops work.
Where These Criticisms Lead
Both problems share a common origin: C++ coroutines are a low-level primitive, not a high-level safe abstraction. The co_await, co_yield, and co_return keywords are hooks into a compiler transformation; they are not safety boundaries. The language ships the mechanism and leaves the policy to library authors and developers.
This is consistent with how C++ approaches everything. std::vector does not prevent out-of-bounds access in release builds. Raw pointers compile without lifetime checks. The language optimizes for giving experts precise control at the cost of making novice mistakes silent. The coroutine design extends that tradition.
The honest critique is not that this design is wrong but that the gap between the coroutine primitive and a safe, ergonomic abstraction is larger than in languages with more opinionated designs. C++23’s std::generator<T> closes the gap for the lazy generator case by providing a standard promise implementation with correct value and reference semantics. The ongoing P2300 std::execution proposal, if it completes for C++26, would standardize the scheduler and structured concurrency story that libraries like Boost.Asio and cppcoro have filled in the meantime.
But std::execution cannot fix dangling reference parameters. That requires lifetime analysis the type system does not have. And a standard scheduler cannot add an async keyword to function declarations retroactively. Those two problems are baked into the design at a level below what library standardization can reach.
What This Means in Practice
None of this is a reason to avoid C++ coroutines. The model works, the performance characteristics are good when the heap allocation is elided, and libraries like Asio make the integration with real I/O systems manageable. Lewis Baker’s original cppcoro and the patterns it established are worth studying even now that some of its functionality has been standardized.
But C++ coroutines require a different kind of defensive discipline than Python’s asyncio or Go’s goroutines. Taking parameters by value for anything that crosses a suspension point is a rule you need to internalize and apply consistently, because the compiler will not remind you. Recognizing coroutine return types as a distinct semantic category, not just a different return type, is a reading skill the code does not teach automatically.
Krzemieński’s article is valuable not because it defends coroutines unconditionally but because it engages with the specific criticisms on their own terms. Some concerns about C++ coroutines are solvable with better tooling, better libraries, and continued standardization. The two he addresses are not in that category, and being clear about which problems belong to which category is more useful than either dismissing the criticisms or treating them as fatal.