Rust
Futures
Lifetimes
Async Programming
Function Arguments

How to bind lifetimes of Futures to fn arguments in Rust

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

When a Rust future borrows from a function argument, the future cannot outlive that borrowed value. The fix is not to fight the borrow checker, but to express the lifetime relationship clearly in the function signature or move owned data into the async task.

Why Lifetimes Matter for Futures

A future may be polled long after the function that created it has returned. If that future contains references to input arguments, Rust must know those references stay valid for the entire lifetime of the future.

That is why borrowed data and returned futures are often tied together with the same lifetime. In plain terms, if the future reads from text, then the future can live only as long as text lives.

Returning impl Future + 'a

For non-async fn APIs, an explicit lifetime bound is often the clearest pattern.

rust
1use std::future::Future;
2
3fn contains_later<'a>(
4    text: &'a str,
5    needle: &'a str,
6) -> impl Future<Output = bool> + 'a {
7    async move {
8        tokio::task::yield_now().await;
9        text.contains(needle)
10    }
11}
12
13#[tokio::main]
14async fn main() {
15    let text = String::from("rust futures and lifetimes");
16    let found = contains_later(&text, "future").await;
17    println!("{}", found);
18}

The + 'a part says the returned future may borrow data that is valid for lifetime 'a. Without that bound, the compiler cannot prove the future is safe to use.

Using async fn

An async fn can often express the same idea more simply because the compiler builds the future type for you.

rust
1async fn count_matches<'a>(text: &'a str, needle: &'a str) -> usize {
2    tokio::task::yield_now().await;
3    text.matches(needle).count()
4}
5
6#[tokio::main]
7async fn main() {
8    let line = String::from("abc abc abc");
9    let count = count_matches(&line, "abc").await;
10    println!("{}", count);
11}

Here the generated future implicitly carries lifetime 'a. The important rule is unchanged: the borrowed inputs must still exist until the future finishes.

When You Need a Boxed Future

Trait objects often require boxed futures. In that case, the lifetime must be attached to the box as well.

rust
1use futures::future::BoxFuture;
2use futures::FutureExt;
3
4fn first_word_later<'a>(text: &'a str) -> BoxFuture<'a, &'a str> {
5    async move {
6        tokio::task::yield_now().await;
7        text.split_whitespace().next().unwrap_or("")
8    }
9    .boxed()
10}

BoxFuture<'a, T> means the boxed future may borrow data valid for 'a. This is common in trait-based async abstractions.

When Borrowing Is the Wrong Choice

Sometimes the API you are calling needs a 'static future, such as when work is spawned onto a background executor. A future that borrows from a local argument usually cannot satisfy that requirement. In that case, move owned data into the async block instead.

rust
1fn spawn_print(text: String) -> tokio::task::JoinHandle<()> {
2    tokio::spawn(async move {
3        println!("{}", text);
4    })
5}

Owning the String avoids lifetime coupling with the caller. If cloning is expensive, use Arc<str> or another shared ownership type rather than forcing a borrowed future into a 'static API.

Common Pitfalls

  • Returning a future that borrows an argument without adding the matching lifetime bound leads to compiler errors. Tie the future lifetime to the borrowed inputs explicitly.
  • Trying to pass a borrowing future into tokio::spawn usually fails because tokio::spawn needs a 'static future. Move owned data into the task instead.
  • Borrowing local temporary values creates futures that outlive the temporary. Store the value in a named variable with a long enough lifetime.
  • Boxing a future without the correct lifetime annotation hides the same ownership problem rather than solving it. Use BoxFuture<'a, T> when the future borrows data.
  • Reaching for complex lifetime tricks too early makes the API harder to use. If ownership is acceptable, passing String, Arc, or cloned values is often the cleaner design.

Summary

  • A future that borrows from function arguments must not outlive those arguments.
  • The usual fix is impl Future<Output = T> + 'a or an async fn whose inputs carry the needed lifetime.
  • Boxed futures also need the lifetime attached, such as BoxFuture<'a, T>.
  • Borrowing futures do not fit APIs that require 'static tasks.
  • When lifetime plumbing becomes awkward, switching to owned data is often the most practical solution.

Course illustration
Course illustration

All Rights Reserved.