Skip to content

Chapter 16

Lifetimes in Practice

We introduced lifetimes briefly in Chapter 3. Now that our server passes borrowed data between parsing, routing, and responses, we need to understand lifetimes for real - and know when to avoid them.

Lifetime Annotations Revisited

Every reference in Rust has a lifetime - the scope during which the reference is valid. Most of the time the compiler infers lifetimes for you. Annotations are only needed when the compiler can't figure out the relationship between input and output references on its own.

A lifetime annotation doesn't change how long data lives. It describes the relationship between lifetimes so the compiler can verify your code is safe. Think of them as documentation the compiler checks.

// No annotation needed - one input reference, one output reference.
// The compiler infers that the output lives as long as the input.
fn first_line(text: &str) -> &str {
    text.lines().next().unwrap_or("")
}

// Annotation needed - two input references, one output.
// Which input does the output reference? The compiler can't guess.
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

The 'a annotation says: "the returned reference lives at least as long as both a and b." The compiler uses this to ensure the caller doesn't use the result after either input is dropped.

Reading the Syntax

// 'a is a lifetime parameter - like a generic type parameter but for lifetimes
fn example<'a>(input: &'a str) -> &'a str { input }
//         ^^          ^^          ^^
//         |           |           |
//     declared    used on      used on
//     here       input         output

// Multiple lifetime parameters
fn pick<'a, 'b>(a: &'a str, b: &'b str, use_first: bool) -> &'a str {
    // Can only return 'a because the return type says 'a
    if use_first { a } else { panic!("must use first") }
}

// 'static - lives for the entire program
let s: &'static str = "I'm a string literal";
// String literals are baked into the binary - they live forever
Note
'static doesn't mean "never freed" or "global variable." It means "this reference is valid for the rest of the program." String literals, leaked boxes, and lazily initialized statics all have 'static lifetimes. You've been using 'static throughout this guide - our Response stores reason: &'static str because HTTP reason phrases are string literals like "OK" and "Not Found".

Lifetimes in Structs and Method Signatures

When a struct holds a reference, you must annotate the lifetime. This tells the compiler that the struct can't outlive the data it borrows:

/// A view into a parsed HTTP request - borrows the raw buffer.
struct RequestView<'a> {
    method: &'a str,
    path: &'a str,
    headers: Vec<(&'a str, &'a str)>,
}

// The struct borrows from 'raw' - it can't outlive the raw string.
fn parse_view(raw: &str) -> Option<RequestView<'_>> {
    let mut lines = raw.split("\r\n");
    let first = lines.next()?;
    let mut parts = first.split_whitespace();

    let method = parts.next()?;
    let path = parts.next()?;

    let headers: Vec<(&str, &str)> = lines
        .take_while(|l| !l.is_empty())
        .filter_map(|l| l.split_once(':'))
        .map(|(k, v)| (k.trim(), v.trim()))
        .collect();

    Some(RequestView { method, path, headers })
}

Compare this to our owned Request struct from earlier chapters:

Borrowed (RequestView<'a>)

  • No allocations - points into the original buffer
  • Very fast to create
  • Can't outlive the buffer
  • Can't be sent to another thread easily
  • Good for: parsing, short-lived processing

Owned (Request)

  • Allocates Strings and Vec
  • Slightly slower to create
  • Can live as long as needed
  • Can be sent to another thread (Send)
  • Good for: storing, passing around, async tasks

Methods on Structs with Lifetimes

impl<'a> RequestView<'a> {
    /// Return a header value. The returned &str borrows from
    /// the same buffer as the RequestView itself.
    fn get_header(&self, name: &str) -> Option<&'a str> {
        self.headers.iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| *v)
    }

    /// Convert to an owned Request - escape the lifetime.
    fn to_owned(&self) -> Request {
        Request {
            method: Method::from_str(self.method),
            path: self.path.to_string(),
            headers: self.headers.iter()
                .map(|(k, v)| (k.to_string(), v.to_string()))
                .collect(),
        }
    }
}

Notice that get_header returns &'a str - the returned reference has the same lifetime as the struct's data, not the lifetime of &self. This means the returned header value can outlive the &self borrow:

let raw = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
let host: &str;
{
    let view = parse_view(raw).unwrap();
    host = view.get_header("Host").unwrap();
    // 'view' is dropped here...
}
// ...but 'host' is still valid because it borrows from 'raw', not from 'view'
println!("Host: {}", host);
Tip
This is a subtle but powerful distinction. The lifetime on get_header's return type is 'a (tied to the buffer), not the implicit lifetime of &self. If it were tied to &self, the host value would be invalid after the view was dropped.

Common Lifetime Patterns and Elision Rules

You've written many functions with references that didn't need lifetime annotations. That's because the compiler follows three elision rules to infer lifetimes automatically:

Rule 1: Each reference parameter gets its own lifetime

// You write:
fn foo(a: &str, b: &str) -> ...
// Compiler infers:
fn foo<'a, 'b>(a: &'a str, b: &'b str) -> ...

Rule 2: If there's exactly one input lifetime, it's assigned to all outputs

// You write:
fn first_word(s: &str) -> &str
// Compiler infers:
fn first_word<'a>(s: &'a str) -> &'a str
// Output must live as long as input - makes sense

Rule 3: If one parameter is &self or &mut self, its lifetime is assigned to all outputs

// You write:
impl Request {
    fn path(&self) -> &str { &self.path }
}
// Compiler infers:
impl Request {
    fn path<'a>(&'a self) -> &'a str { &self.path }
}
// Output lives as long as self - makes sense for methods

If these three rules don't fully determine the output lifetimes, the compiler asks you to annotate. Here's when you need annotations:

SituationNeeds annotation?
One input ref, one output refNo (rule 2)
Method returning &str from &selfNo (rule 3)
Two input refs, return one of themYes - which one?
Struct holding a referenceAlways
Returning a reference to a localImpossible - return owned

The Anonymous Lifetime: '_

In some positions, you can use '_ to let the compiler fill in the lifetime. It's useful in return types and impl blocks to reduce noise:

// These are equivalent:
fn parse_view<'a>(raw: &'a str) -> Option<RequestView<'a>> { ... }
fn parse_view(raw: &str) -> Option<RequestView<'_>> { ... }

// In impl blocks:
impl<'a> RequestView<'a> { ... }
impl RequestView<'_> { ... }  // if 'a isn't needed in method signatures

Lifetime Issues in Request/Response Handling

Let's look at real lifetime problems you hit when building a server, and how to solve each one.

Problem 1: Returning a Reference to a Local

// This won't compile:
fn build_greeting(name: &str) -> &str {
    let greeting = format!("Hello, {}!", name);
    &greeting  // ERROR: 'greeting' is dropped at the end of the function
}

// Fix: return the owned String
fn build_greeting(name: &str) -> String {
    format!("Hello, {}!", name)
}

This is the most common lifetime error. You can't return a reference to data created inside the function - it would be a dangling pointer. Return an owned type instead.

Problem 2: Storing Borrowed Data Too Long

// We read bytes into a buffer, parse them, and want to route.
// But the parsed request borrows the buffer...

async fn handle(mut stream: TcpStream, router: &Router) {
    let mut buffer = [0u8; 4096];
    let n = stream.read(&mut buffer).await.unwrap();
    let raw = String::from_utf8_lossy(&buffer[..n]);

    // RequestView borrows 'raw'
    let view = parse_view(&raw).unwrap();

    // If we try to send 'view' to another task:
    // tokio::spawn(async move {
    //     router.route(&view);  // ERROR: view borrows local data
    // });

    // Fix: convert to owned before crossing boundaries
    let request = view.to_owned();
    // Now 'request' owns its data and can go anywhere
}

Problem 3: References in Closures

// Router stores handlers as closures. If a handler borrows
// from its environment, the closure's lifetime matters:

fn build_router() -> Router {
    let mut router = Router::new();

    let template = String::from("<h1>Welcome, {name}</h1>");

    // This won't compile - 'template' is dropped when build_router returns,
    // but the closure borrows it:
    // router.add(Method::Get, "/", |req| {
    //     let html = template.replace("{name}", "user");
    //     Ok(Response::html(&html))
    // });

    // Fix 1: move ownership into the closure
    router.add(Method::Get, "/", move |_req| {
        let html = template.replace("{name}", "user");
        Ok(Response::html(&html))
    });

    // Fix 2: use 'static data
    router.add(Method::Get, "/about", |_req| {
        Ok(Response::html("<h1>About</h1>")) // string literal is 'static
    });

    router
}

Problem 4: Multiple Borrows with Different Lifetimes

// A response builder that borrows from both the request and a template:
struct ResponseBuilder<'req, 'tmpl> {
    request: &'req Request,
    template: &'tmpl str,
}

impl<'req, 'tmpl> ResponseBuilder<'req, 'tmpl> {
    fn build(&self) -> Response {
        let body = self.template
            .replace("{path}", &self.request.path)
            .replace("{method}", &self.request.method.to_string());
        Response::html(&body)
    }
}

// The builder can't outlive either the request OR the template.
// Two separate lifetimes because the data comes from different sources
// and may live for different durations.
Note
Multiple lifetime parameters are rare in practice. Most of the time, one lifetime (or no lifetimes) is enough. If you find yourself writing <'a, 'b, 'c>, consider whether some of those references should be owned instead.

When to Clone vs When to Borrow

This is the most practical question in day-to-day Rust. The answer depends on the situation, but here are clear guidelines:

Prefer Borrowing When:

The function only reads the data. Pass &str instead of String, &[T] instead of Vec<T>.

// Good - borrows, doesn't allocate
fn content_length(body: &str) -> usize { body.len() }

// Wasteful - takes ownership just to read
fn content_length(body: String) -> usize { body.len() }

The data lives long enough. If the owner outlives all borrowers, there's no reason to copy.

Performance matters on a hot path. Parsing headers in a tight loop? Borrow from the raw buffer instead of allocating strings for each header.

Prefer Cloning/Owning When:

Data crosses thread or task boundaries. tokio::spawn requires 'static. You can't send borrows to other threads - clone or use Arc.

Data is stored in a long-lived struct. If a struct outlives the data it would borrow from, it must own the data. Our Request owns String fields because it outlives the read buffer.

Lifetime annotations get complicated. If adding a borrow creates a cascade of lifetime parameters across five functions, cloning a small string is simpler and barely slower.

The data is small. Cloning a u64, a small String, or a PathBuf is nearly free. Don't contort your code to avoid a trivial clone.

Tip
A common Rust beginner mistake is fighting the borrow checker to avoid every clone. The right approach: start with owned types. When profiling shows a hot path where allocations matter, introduce borrows there. Correct and simple first, then optimize.

The Pattern in Our Server

Our server uses a deliberate strategy:

// 1. Read bytes into a buffer (owned by the connection handler)
let mut buffer = [0u8; 4096];
let n = stream.read(&mut buffer).await?;
let raw = String::from_utf8_lossy(&buffer[..n]);

// 2. Parse into owned types (allocates Strings)
// We COULD parse into borrowed &str slices, but then
// the Request couldn't outlive this function or be sent
// to a thread pool.
let request = Request::parse(&raw)?;

// 3. Route and build response (borrows the request)
let response = router.route(&request)?;
//                          ^^^^^^^^
//                          borrowed - route just reads it

// 4. Serialize and send (owned bytes)
stream.write_all(&response.to_bytes()).await?;

// Ownership boundary: parse creates owned data,
// routing borrows it, serialization creates owned bytes.

The allocations happen once during parsing. Everything after that borrows. If we needed zero-copy parsing (for extreme performance), we'd use RequestView<'a> instead - but we'd lose the ability to pass the request to async tasks or store it.

Exercise

  1. Write a HeaderMap<'a> struct that holds Vec<(&'a str, &'a str)> (borrowing from the raw request). Add a get method that returns Option<&'a str>. Verify that the returned value can outlive the &self borrow.
  2. Try creating a RequestView inside an async function and sending it to a tokio::spawn task. Read the compiler error. Fix it by converting to an owned Request before spawning.
  3. Write a function fn pick_header<'a>(req: &'a Request, names: &[&str]) -> Option<&'a str> that returns the value of the first matching header. Why does the output lifetime come from req and not names?
  4. Refactor one handler to use Cow<'_, str> (from std::borrow::Cow) for a response body that's sometimes a static string and sometimes dynamically built. Where does this save an allocation?
  5. Add #[derive(Clone)] to Request. Measure the cost: clone a request 1,000,000 times in a loop and print the elapsed time. Now do the same with a borrow. How big is the difference for a typical HTTP request?

What's Next

Lifetimes are no longer mysterious. You know the elision rules, when annotations are needed, how lifetimes work in structs and methods, and - most importantly - when to just clone instead of wrestling with the borrow checker.

In the next chapter, we explore smart pointers and interior mutability - Box, Rc, Arc, RefCell, and Cow. These types give you more control over ownership, sharing, and mutation when the default rules aren't enough.