Lesson 2 of 9 · 20m
Lifetimes in Real Interfaces
Elision rules, zero-copy views, why tokio::spawn rejects your reference, and Cow for maybe-borrowed returns.
Lifetimes are where most engineers decide Rust isn't worth it — usually at 11pm, fighting a RequestView<'a> that won't compile inside tokio::spawn. The fix is almost never "learn more lifetime syntax." It's understanding which of three situations you're in, and they each have a different answer.
The three situations
1. The compiler handles it and you're fine. Lifetime elision covers the majority of signatures. These two are identical:
fn first(s: &str) -> &str // elided
fn first<'a>(s: &'a str) -> &'a str // what the compiler seesElision gives each input reference its own lifetime and assigns the output to the lifetime of self (if present) or the single input lifetime. When elision can't unambiguously pick an output lifetime — two or more input references and no self — that's when you write <'a>.
2. You return a borrow, and the caller must know what it lives as long as. Zero-copy parsing is the textbook case:
struct RequestView<'a> {
method: &'a str,
path: &'a str,
}
fn parse(raw: &str) -> Option<RequestView<'_>> {
let mut parts = raw.split(' ');
Some(RequestView {
method: parts.next()?,
path: parts.next()?,
})
}RequestView<'a> borrows from raw. The 'a on the struct says "this struct cannot outlive whatever it points into." Cheap, no allocations — and raw must stay alive as long as the view does.
3. You're trying to store a borrow in something long-lived. This is the 11pm case, and the answer is usually stop trying.
Why tokio::spawn rejects your reference
async fn handle(raw: String) {
let view = parse(&raw).unwrap();
tokio::spawn(async move {
println!("{}", view.path); // ERROR: `view` may not live long enough
});
}tokio::spawn requires 'static: the task could outlive handle entirely — the runtime may keep it queued after handle returns and raw is dropped. A RequestView<'a> pointing into raw would be a dangling pointer the moment handle exits.
Your options, in order of preference:
- Do the borrowed work before spawning. Extract what you need, spawn with owned copies.
- Share ownership with
Arc<str>— cheap refcounted string slices if many tasks need the same text. - Spawn inside the borrowed scope with
tokio::task::scope(orstd::thread::scopein sync code), which guarantees tasks complete before the scope returns — letting them borrow.
let path: Arc<str> = Arc::from(view.path);
tokio::spawn(async move { log_request(&path).await });Cow: borrow usually, own when you must
std::borrow::Cow ("clone on write") is the underused fix for functions that usually return a slice of input but sometimes must build something new:
use std::borrow::Cow;
fn normalize(input: &str) -> Cow<'_, str> {
if input.is_ascii() {
Cow::Borrowed(input) // zero-cost path
} else {
Cow::Owned(input.to_lowercase()) // allocated only when needed
}
}The caller gets &str ergonomics; you only allocate on the rare path. JSON decoders, path normalizers, and HTML escapers all use this shape.
The bug that ships
The lifetime bug that survives review isn't a compile error — the compiler won't let you ship a dangling reference. It's the design bug: a &'a str field in a struct that has to cross a serialization boundary, forcing .to_string() calls at the exact hot path you were trying to keep allocation-free. If your "zero-copy" view gets converted to owned data three functions later anyway, you paid complexity for nothing. Rule of thumb: borrowed views earn their keep only when they stay inside one request/task lifetime.
Try this
Write a function fn user_agent(headers: &str) -> Option<&str> that finds User-Agent: in a raw header block and returns the rest of that line without allocating. Compile it. Now try to return it from an async fn that tokio::spawns — feel the 'static wall yourself, then fix it with Arc<str>.
Field notes
'staticdoes not mean "lives forever" in a scary way — it means "no borrow shorter than the whole program."String,Vec, andArcall satisfy'static. That's whytokio::spawnaccepts them.- When the compiler says "cannot return reference to local variable," it means it. Build an owned return type or pass in a buffer (
&mut String). impl<'a> RequestView<'a>blocks that only useself.methoddon't need lifetime annotations on individual methods — annotate the struct, not every function.