Lambdas That Crash Later: Dangling Reference Captures and How to Capture Safely

Key takeaways

Why a stored C++ lambda can crash long after it was created: what a capture really is, how [&] and the implicit this in [=] dangle, why move-only captures break std::function, and the patterns (value, init-capture, shared_ptr, weak_ptr) that keep captured state alive.

The symptom: correct code that fails somewhere else

A dangling capture is one of the few C++ bugs where the crash is nowhere near the mistake. The lambda is created in one function, looks correct, and passes its first test. The failure happens later, when some callback queue, timer or thread finally invokes it, and by then the stack frame or object it referred to is gone.

std::function<int()> makeGetter() {
    int x = 42;
    return [&x]() { return x; };   // captures a reference to a local
}                                   // x is destroyed here

int main() {
    auto get = makeGetter();
    std::cout << get() << '\n';     // undefined behavior
}

This usually compiles without a warning. When run, it may print 42, print garbage, or crash, and the result can change with optimization level. That variability is why these bugs survive testing: in a debug build the dead stack slot often still holds the old value.

What a capture actually is

A lambda expression creates an object of an unnamed class. Each capture becomes a data member of that class, and the body becomes its operator(). Roughly:

// [x, &y](int a) { return x + y + a; }
struct __lambda {
    int  x;     // captured by value: a copy
    int& y;     // captured by reference: just an alias
    int operator()(int a) const { return x + y + a; }
};

Seen this way, the rules stop being surprising. A value capture is a copy taken when the lambda is created, not when it is called. A reference capture is a reference member, and a reference member has no power to keep its target alive. If the lambda object outlives the variable, the member refers to nothing.

A short program shows the timing:

int x = 10;
auto byValue = [x]  { return x * 2; };
auto byRef   = [&x] { return x * 2; };
x = 20;
std::cout << byValue() << ' ' << byRef() << '\n';   // prints: 20 40

byValue copied 10 at creation, so it returns 20. byRef reads the current value, 20, and returns 40.

Why the call operator is const, and what mutable does

int c = 0;
auto l = [c] { c = 2; };
error: assignment of read-only variable 'c'

The generated operator() is const, so value-captured members are read-only. mutable removes that const, which lets the lambda update its own copy:

int c = 0;
auto counter = [c]() mutable { return ++c; };
counter(); counter();
std::cout << counter() << ' ' << c << '\n';   // prints: 3 0

The state persists across calls because it lives in the lambda object. Copy the lambda and you copy the state, which surprises people who pass a mutable lambda to an algorithm that copies its function object.

The four ways captures dangle

Returning or storing a lambda that captures locals by reference

The opening example. [&] is worse than [&x] here because it captures everything the body mentions by reference without listing any of it, so a later edit to the body can silently introduce a new dangling reference.

Fix: capture by value, [x] or [=], whenever the lambda leaves the scope.

The hidden this pointer in [=]

struct Widget {
    int id = 7;
    std::function<int()> callback() {
        return [=] { return id; };   // really: this->id
    }
};

[=] does not copy id. Members are not local variables; the lambda captures this and reads this->id. If the Widget is destroyed before the callback runs, the lambda dereferences a dangling pointer, despite the reassuring =. C++20 deprecated exactly this, and GCC says so:

warning: implicit capture of 'this' via '[=]' is deprecated in C++20 [-Wdeprecated]

Fixes, depending on what you need:

return [id = id] { return id; };   // copy just the member (C++14 init-capture)
return [*this] { return id; };     // copy the whole object (C++17)
return [this] { return id; };      // explicit: only if the object surely outlives the lambda

Async work outliving its caller

void startUpload(const std::string& path) {
    std::thread([&] { upload(path); }).detach();
}

path refers to the caller’s string, which may be destroyed the moment startUpload returns. Any lambda passed to a thread, a thread pool, an event loop or a std::async that you do not immediately wait on should capture by value: [path] { upload(path); } copies the string into the lambda.

Capturing an object whose lifetime is shared

When the callback needs an object that is owned elsewhere and might be destroyed first, copying it is not an option. Two patterns:

auto sp = std::make_shared<Session>();

// keep it alive for as long as the callback exists
timer.onFire([sp] { sp->ping(); });

// or: do not extend its life, just check whether it still exists
std::weak_ptr<Session> wp = sp;
timer.onFire([wp] {
    if (auto s = wp.lock()) s->ping();
});

The shared_ptr capture is simple but can create reference cycles: if Session owns the timer and the timer owns a lambda holding a shared_ptr<Session>, neither is ever freed. The weak_ptr version breaks the cycle and turns “use after free” into a checked “the object is gone, do nothing”.

Inside a class, the same idea is spelled [self = shared_from_this()] or [weak = weak_from_this()] (the latter is C++17), with the class deriving from std::enable_shared_from_this.

Move-only captures and std::function

Init-captures let you move ownership into a lambda:

auto p = std::make_unique<Buffer>();
auto task = [buf = std::move(p)] { process(*buf); };

This is the cleanest lifetime fix of all: the lambda owns what it uses. But the lambda is now move-only, and std::function requires a copyable callable:

std::function<void()> f = [q = std::move(p)] {};
error: use of deleted function 'main()::<lambda()>::<lambda>(const main()::<lambda()>&)'
error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) ...'

The first line says the lambda’s copy constructor is deleted; the second says why: its unique_ptr member cannot be copied. Keep the lambda in auto, pass it as a template parameter, use C++23 std::move_only_function, or, if you must go through std::function, capture a shared_ptr instead.

Loop variables

Capturing a loop variable by value does what you want:

std::vector<std::function<int()>> fs;
for (int i = 0; i < 3; ++i) fs.push_back([i] { return i; });
for (auto& f : fs) std::cout << f();   // prints: 012

With [&i] every lambda would refer to the same i, which no longer exists once the loop ends. This is less of a trap in C++ than in JavaScript, but only because [i] is the natural spelling; [&] reproduces the classic bug.

Two less obvious cases

Coroutine lambdas. A lambda whose body uses co_await is a coroutine, and its captures are members of the lambda object, not of the coroutine frame. The frame only stores the implicit this pointer of the lambda. So this pattern dangles even though everything is captured by value:

Task<void> start(std::string name) {
    auto job = [name]() -> Task<void> {
        co_await sleepFor(1s);
        log(name);            // reads a member of the lambda object
    };
    return job();             // job (and its copy of name) dies when start returns
}

Calling [name]() -> Task<void> { ... }() directly on a temporary is the same bug. The fix is to pass the data as a coroutine parameter, which the frame does copy: [](std::string name) -> Task<void> { ... }(name), or to keep the lambda object alive until the task finishes. The C++ Core Guidelines list this as CP.51 (“Do not use capturing lambdas that are coroutines”).

Things that are not captured at all. Globals, static locals and static members are used directly, not captured, so [=] does not snapshot them; the lambda sees whatever value they have when it runs. Structured bindings could not be captured at all before C++20 (the exact error wording differs between compilers); C++20 allows [k] for them, and on older standards you write [k = k].

Where this goes wrong in practice

The version I see most often is not the textbook “return a lambda capturing a local”. It is a class that registers [this] or [=] callbacks with an event system and forgets to unregister them in its destructor. Everything works until an object is destroyed while a callback is still queued, and then the crash appears in the event loop, with a stack trace that contains no frame from the class that caused it. When I see an event-loop crash with a lambda near the top of the stack, the first question is always which object that lambda’s this pointed to and whether it was still alive.

The second is [&] in a lambda that started out being called synchronously, which is completely safe, and was later changed to be posted to a thread pool “for performance”. The capture list did not change, so the diff looked harmless. Changing when a lambda runs is a lifetime change, and every reference capture needs to be reconsidered when that happens.

Finding these bugs

  • AddressSanitizer (-fsanitize=address -g) reports the access as stack-use-after-return or heap-use-after-free and shows where the memory was freed. Detection of stack-use-after-return may need ASAN_OPTIONS=detect_stack_use_after_return=1 depending on your compiler version.
  • Compile with the latest standard mode you can: the C++20 [=]/this deprecation warning is a free audit of every implicit this capture.
  • Review rule: any & in a capture list of a lambda that is stored, returned or sent elsewhere needs a comment explaining why the referent outlives it.

FAQ

Q. How do I capture a unique_ptr in a lambda?

A. Use an init-capture, [p = std::move(ptr)] { ... }. The lambda becomes move-only, so keep it in auto, pass it as a template parameter, or use std::move_only_function (C++23) rather than std::function.

Q. Is [&] ever the right default?

A. Yes, for lambdas that are consumed immediately, such as predicates to std::find_if or comparators to std::sort. There the lambda cannot outlive anything, and reference capture avoids copies.