The async borrowing trap
You write an async function to fetch a webpage and extract a title. It takes a URL string slice and returns an Option<String>. The compiler accepts it. You change the return type to Option<&str> to avoid allocating. The compiler rejects you. You add a lifetime annotation. It still rejects you. The error message points to a temporary value that gets dropped too early. Async functions change how you think about borrowing because the code does not run to completion before returning. It hands you a Future that holds onto your references until you actually poll it.
How the compiler actually handles it
Lifetimes in async functions work exactly like lifetimes in sync functions. The difference is timing. A normal function runs, returns, and drops its local variables immediately. An async function compiles into a state machine. That state machine lives on the heap or stack until you poll it to completion. Any reference you pass into the async function gets captured by that state machine. The lifetime annotation tells the compiler how long the state machine promises to keep that reference alive.
Think of it like handing a library book to a courier. The courier does not read the book immediately. They hold it in their van until they reach the destination. The lifetime is the contract that says the book will not be recalled by the library while the courier is still driving. The compiler enforces that contract at compile time, not at runtime.
Minimal example
/// Returns the first word from a string slice.
async fn first_word<'a>(text: &'a str) -> &'a str {
// The compiler generates a Future struct that holds this reference.
// The lifetime 'a is attached to the future itself.
text.split_whitespace().next().unwrap_or("")
}
When you call first_word("hello world"), Rust does not execute the function body. It builds a Future struct. That struct contains a pointer to the string slice and a state enum. The lifetime 'a is baked into the struct definition. The future itself carries the lifetime. If you drop the original string before polling the future, the compiler stops you. The future would hold a dangling pointer.
Trust the borrow checker here. It is tracking the exact memory layout of the generated state machine.
Walking through the state machine
The compiler transforms async fn into fn -> impl Future<Output = ...>. That future struct has fields for every variable live across an .await point. References become fields with lifetime parameters. The compiler tracks those lifetimes through the state transitions. When you .await, the future yields control. The references stay pinned in the struct. They do not move. They do not get dropped. They wait until the future reaches its final state.
If you try to return a reference to a local variable created inside the async block, the compiler catches it. The local variable dies when the future is created, not when it is polled. The future would outlive the data it points to. The compiler rejects this with E0515 (cannot return reference to local variable). The error points to the exact line where the local scope ends. You cannot cheat the compiler by adding unsafe or raw pointers. The state machine layout is fixed at compile time.
Realistic workflow
Let's look at a common pattern that trips people up. You fetch raw data asynchronously, parse it, and want to return a reference to a specific field.
/// Simulates fetching and parsing a config file.
async fn parse_config<'a>(raw: &'a str) -> &'a str {
// Simulate network delay without blocking the thread.
// The reference to `raw` is captured by the generated future.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
// Return a slice of the original input.
// This works because the slice points to `raw`, not a local.
raw.trim()
}
This compiles. The returned slice points directly to raw. The future holds &'a str, and the output is &'a str. The lifetimes match. Now break it intentionally.
/// Attempts to return a reference to a locally owned string.
async fn broken<'a>(input: &'a str) -> &'a str {
// Creates an owned String inside the future state machine.
let processed = format!("processed: {}", input);
// ERROR: E0515 cannot return reference to local variable `processed`
// The future would hand out a pointer to memory it owns.
&processed
}
The compiler rejects this immediately. processed is owned by the future state machine. When the future completes, processed drops. Returning a reference to it would hand out a pointer to freed memory. The fix is usually to return an owned String instead. Async boundaries are expensive places to leak allocations, but they are cheap places to move data.
Return owned types across async boundaries. Borrowing works best when the data outlives the entire event loop.
Common pitfalls and compiler errors
You will run into three specific lifetime traps with async code. The first is capturing references in async blocks. An async { } block captures variables by reference by default. If you pass a mutable reference into an async block, the block borrows it for the entire lifetime of the generated future. The compiler enforces this with E0502 (cannot borrow as mutable because it is also borrowed as immutable) when you try to mutate the original variable while the future is pending. The future holds an immutable borrow, and you are trying to take a mutable one. The solution is to clone the data or use interior mutability.
The second trap is temporary values. If you call a function that returns an owned type inside an async block and immediately borrow from it, you trigger E0716 (temporary value dropped while borrowed). The temporary lives only until the end of the statement, but the future needs it to survive across .await. The compiler sees the temporary dying before the future yields. Bind the temporary to a let variable so it lives for the entire future scope.
The third trap is the static lifetime. Some async runtimes require futures to be 'static. This means they cannot borrow anything from the calling scope. You get E0310 (the parameter type may not live long enough) when you try to pass a non-static future to a function that expects 'static. The solution is to clone data into the future or use reference counting. The runtime cannot guarantee your stack frame will stay alive while the task runs on a different thread.
Pin your futures when you implement custom async types. Move data into async blocks when you need 'static futures.
Decision matrix
Use explicit lifetime annotations when an async function returns a reference tied to an input argument. Use lifetime elision when the function takes one reference and returns a reference to the same data. Return owned types like String or Vec when the async boundary creates temporary data that cannot outlive the function. Reach for Arc or Rc when multiple async tasks need to share mutable or immutable state across task boundaries. Pick move async blocks when you need to capture owned data and erase stack borrows. Trust the borrow checker when it rejects a reference. It is protecting you from a dangling pointer that will only show up after the event loop runs.
Convention asides
The community prefers descriptive lifetime names over 'a when the scope spans multiple functions or modules. Use 'input or 'ctx instead of 'a for clarity. Async functions automatically implement #[must_use]. Ignoring a future without .awaiting it triggers a warning. This prevents silent drops of work that was supposed to run. When you see async fn in the wild, remember it is just syntactic sugar for returning impl Future. The generated future struct is opaque to you, but the lifetime rules apply to it exactly as they would to a manually written struct. Keep your async functions small. Fewer variables across .await points mean simpler state machines and fewer lifetime headaches.