When Coroutines Betray You: A Lambda Lifetime Bug You Don’t See Coming
- alex d
- 5 days ago
- 3 min read

Coroutines are powerful.They make asynchronous code readable, composable, and expressive.But they are also merciless when it comes to lifetime mistakes.
In this post, I want to walk through a real bug that looks completely harmless at first glance — modern C++, clean abstractions, universal references — and still ends up as a data-integrity time bomb.
This is not a coroutine bug.This is a fundamentals bug, made visible by coroutines.
The Setup: Everything Looks Fine
Imagine the following scenario:
We have a coroutine that performs an operation with retries
Up to 10 instances of this coroutine may run concurrently
A helper function with_retries takes a callable and retries it on failure
The callable is passed as an inline lambda
Conceptually, the flow looks like this:
Coroutine A starts
It calls with_retries
with_retries repeatedly invokes the lambda
Other coroutines may be running at the same time
Nothing unusual here.This is a very common and very reasonable abstraction.
The with_retries function is implemented using a universal reference:
feature<> with_retries(auto&& f);When its called We’re passing a temporary lambda an rvalue exactly what universal references are meant for.
So… what’s the problem?
The Subtle Bug: Lifetime, Not Value Category
Let’s break it down carefully.
The lambda is created at the call site
It is passed as an rvalue reference
Inside with_retries, the parameter is never materialized
That means no copy, no move — just a reference
So what does F&& f actually refer to?
👉 It refers to a lambda object that still lives on the caller’s stack.
Now introduce coroutines.
Coroutines Change the Rules
When a coroutine suspends:
Its execution is paused
Control returns to the caller
The caller’s stack frame may be destroyed
That’s exactly what happens here:
Coroutine starts and calls with_retries
with_retries schedules work and suspends
The calling coroutine completes
The lambda is destroyed
with_retries resumes
💥 Dangling reference
No warnings. No crashes (at least not immediately).
Just undefined behavior waiting to happen and in the end if all starts will align it will explode.
“But I Didn’t std::forward It!”
That’s part of the trap.
Even if you don’t forward the callable, the parameter f is still:
A reference
Bound to a temporary
Referring to an object not owned by with_retries
Yes, f is an lvalue expression inside the function —but it does not represent an object with extended lifetime.
This distinction matters a lot once suspension enters the picture.
The Assembly Tells the Truth
lea rdi, [rbp - 208]
mov qword ptr [rbp - 240], rdi
lea rsi, [rbp - 56]
call std::vector<int, std::allocator<int>>::vector(std::vector<int, std::allocator<int>>&&)
mov rdi, qword ptr [rbp - 240]
call void with_retries<main::$_0>(main::$_0&&)
jmp .LBB0_3 lea rdi, [rbp - 208]
call main::$_0::~$_0() [base object destructor]If you look at the generated assembly, the story becomes obvious:
The lambda is constructed on the stack
Its address is passed into with_retries
When the caller exits, the lambda destructor runs
Later, with_retries calls operator() on freed memory
The code looks high-level and safe.The assembly is brutally honest.
The Fix: Own What You Use
The correct fix is simple and fundamental:
Take the callable by value.
future<> with_retries(auto f);Now:
The lambda is moved into with_retries
Its lifetime is tied to the coroutine frame
Suspension is safe
Retries are safe
Data integrity is preserved
Why not co_await with_retries?
That would technically fix the lifetime issue but it defeats the design goal.
The whole point is to:
Start work
Allow other coroutines to run
Let with_retries finish independently
So awaiting it is the wrong semantic choice here (maybe its good in your case).
The Bigger Lesson
This bug has nothing to do with retries.
Nothing to do with lambdas.Nothing to do with templates.
It’s about ownership and lifetime.
Coroutines don’t create lifetime bugs they expose them.
Much faster than threads ever did.
If you don’t have a rock-solid understanding of:
Value categories
References vs ownership
Object lifetime across suspension
Coroutines will find the cracks in your mental model.
Final Thought
Modern C++ gives us incredible tools.Coroutines are one of the best additions we’ve ever had.
But they demand respect.
Because in asynchronous code,the basics are never optional and never “basic.” 🚀
